Changes Summary

appsettings.json — Added PoolEnabled to each API section:
- TumblrApi → true
- TumblrApi3 → false
- TumblrApi4 → true
DataAccess.cs — Added:
- ApiKeyConfig class — holds credentials + metadata per key
- ApiKeyPool class — manages pool with round-robin rotation, SQLite-backed state (ApiKeyPoolState, ApiKeyPoolMeta tables)
  - GetCurrentKey() — returns next key, skipping rate-limited ones, falls back to earliest-recovery if all are throttled
  - MarkRateLimited(key, retryUntil) / MarkAvailable(key) — persists state
  - Initialize() — discovers pool-enabled keys, detects single-key override mode
- Refactored APIAccess.GrabNotes(), GrabPostWithReplies(), GrabLikes() to accept ApiKeyConfig param and log [Key#N]
Program.cs — Updated:
- Tracks apiExplicitlySet flag from -api/-api3/-api4
- Initializes ApiKeyPool at startup (pool mode or single-key override)
- All 3 API callers updated to use pool rotation + 429 handling
Startup Output
- Pool mode: [Pool] Active keys: Key#1=TumblrApi, Key#2=TumblrApi4
- Single-key: [Pool] Single-key mode: TumblrApi3
This commit is contained in:
jim
2026-05-03 13:31:28 -05:00
parent de96c7c1c5
commit 6cec7afb53
3 changed files with 485 additions and 270 deletions
+417 -244
View File
@@ -1547,51 +1547,267 @@ namespace URLNotesGrabberCORE
#endregion Updates #endregion Updates
} }
internal class APIAccess public class ApiKeyConfig
{ {
public static string Blog { get; set; } = string.Empty; public string SectionName { get; set; } = string.Empty;
private static IConfiguration Configuration { get; set; } = null!; public int KeyNumber { get; set; }
private static string ApiConfigSection { get; set; } = "TumblrApi"; public string ConsumerKey { get; set; } = string.Empty;
public string ConsumerSecret { get; set; } = string.Empty;
public string OAuthToken { get; set; } = string.Empty;
public string OAuthTokenSecret { get; set; } = string.Empty;
public bool PoolEnabled { get; set; }
}
static APIAccess() internal class ApiKeyPool
{
private static List<ApiKeyConfig> _keys = new();
private static ApiKeyConfig? _overrideKey = null;
private static bool _usePool = false;
private static int _currentIndex = 0;
private static string _dbPath = string.Empty;
public static bool IsPoolActive => _usePool;
public static List<ApiKeyConfig> Keys => _keys;
public static void Initialize(IConfiguration config, string? dbPath, string? overrideSection = null)
{ {
Configuration = new ConfigurationBuilder() _dbPath = dbPath ?? "..\\..\\..\\tl.db";
.SetBasePath(Directory.GetCurrentDirectory()) EnsureStateTableExists();
.AddJsonFile("appsettings.json", optional: false, reloadOnChange: true)
.Build();
ValidateApiSection(ApiConfigSection); if (!string.IsNullOrEmpty(overrideSection))
}
public static void SetApiConfigSection(string sectionName)
{
if (string.IsNullOrWhiteSpace(sectionName))
sectionName = "TumblrApi";
ValidateApiSection(sectionName);
ApiConfigSection = sectionName;
Console.WriteLine($"Using API settings section: {ApiConfigSection}");
}
private static void ValidateApiSection(string sectionName)
{
if (Configuration[$"{sectionName}:ConsumerKey"] == null ||
Configuration[$"{sectionName}:ConsumerSecret"] == null ||
Configuration[$"{sectionName}:OAuthToken"] == null ||
Configuration[$"{sectionName}:OAuthTokenSecret"] == null)
{ {
throw new InvalidOperationException($"{sectionName} configuration is missing required keys in appsettings.json"); var section = config.GetSection(overrideSection);
if (section["ConsumerKey"] == null)
throw new InvalidOperationException($"API section '{overrideSection}' not found in appsettings.json");
_overrideKey = new ApiKeyConfig
{
SectionName = overrideSection,
KeyNumber = 1,
ConsumerKey = section["ConsumerKey"]!,
ConsumerSecret = section["ConsumerSecret"]!,
OAuthToken = section["OAuthToken"]!,
OAuthTokenSecret = section["OAuthTokenSecret"]!,
PoolEnabled = true
};
_usePool = false;
Console.WriteLine($"[Pool] Single-key mode: {overrideSection}");
return;
}
var root = config.AsEnumerable()
.Where(kv => kv.Key.Contains(":ConsumerKey"))
.Select(kv => kv.Key.Split(':')[0])
.Distinct()
.ToList();
int keyNum = 1;
foreach (var sectionName in root.OrderBy(s => s))
{
var section = config.GetSection(sectionName);
var poolEnabledStr = section["PoolEnabled"];
bool poolEnabled = !string.IsNullOrEmpty(poolEnabledStr) && bool.TryParse(poolEnabledStr, out var parsed) && parsed;
if (poolEnabled)
{
_keys.Add(new ApiKeyConfig
{
SectionName = sectionName,
KeyNumber = keyNum,
ConsumerKey = section["ConsumerKey"]!,
ConsumerSecret = section["ConsumerSecret"]!,
OAuthToken = section["OAuthToken"]!,
OAuthTokenSecret = section["OAuthTokenSecret"]!,
PoolEnabled = true
});
keyNum++;
}
}
if (_keys.Count == 0)
{
var fallback = config.GetSection("TumblrApi");
if (fallback["ConsumerKey"] != null)
{
_keys.Add(new ApiKeyConfig
{
SectionName = "TumblrApi",
KeyNumber = 1,
ConsumerKey = fallback["ConsumerKey"]!,
ConsumerSecret = fallback["ConsumerSecret"]!,
OAuthToken = fallback["OAuthToken"]!,
OAuthTokenSecret = fallback["OAuthTokenSecret"]!,
PoolEnabled = true
});
Console.WriteLine("[Pool] No keys with PoolEnabled, falling back to TumblrApi");
}
}
_usePool = true;
LoadState();
var mapping = string.Join(", ", _keys.Select(k => $"Key#{k.KeyNumber}={k.SectionName}"));
Console.WriteLine($"[Pool] Active keys: {mapping}");
}
private static void EnsureStateTableExists()
{
using var conn = new System.Data.SQLite.SQLiteConnection("Data Source=" + _dbPath);
conn.Open();
using var cmd1 = new System.Data.SQLite.SQLiteCommand(
"CREATE TABLE IF NOT EXISTS ApiKeyPoolState (KeyName TEXT PRIMARY KEY, RetryUntil INTEGER DEFAULT 0)", conn);
cmd1.ExecuteNonQuery();
using var cmd2 = new System.Data.SQLite.SQLiteCommand(
"CREATE TABLE IF NOT EXISTS ApiKeyPoolMeta (Id INTEGER PRIMARY KEY CHECK (Id = 1), LastIndex INTEGER DEFAULT 0)", conn);
cmd2.ExecuteNonQuery();
}
private static void LoadState()
{
using var conn = new System.Data.SQLite.SQLiteConnection("Data Source=" + _dbPath);
conn.Open();
using var cmd = new System.Data.SQLite.SQLiteCommand("SELECT LastIndex FROM ApiKeyPoolMeta WHERE Id = 1", conn);
var idx = cmd.ExecuteScalar();
if (idx != null)
_currentIndex = Convert.ToInt32(idx);
var now = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
foreach (var key in _keys)
{
using var readCmd = new System.Data.SQLite.SQLiteCommand(
"SELECT RetryUntil FROM ApiKeyPoolState WHERE KeyName = @name", conn);
readCmd.Parameters.AddWithValue("@name", key.SectionName);
var retryUntil = readCmd.ExecuteScalar();
if (retryUntil != null)
{
var retryTs = Convert.ToInt64(retryUntil);
if (retryTs > now)
{
var remaining = retryTs - now;
Console.WriteLine($"[Pool] Key#{key.KeyNumber} ({key.SectionName}) rate-limited, clears in {remaining}s");
}
else if (retryTs > 0)
{
Console.WriteLine($"[Pool] Key#{key.KeyNumber} ({key.SectionName}) rate-limit cleared on startup");
}
}
} }
} }
private static string ConsumerKey => Configuration[$"{ApiConfigSection}:ConsumerKey"] ?? public static ApiKeyConfig GetCurrentKey()
throw new InvalidOperationException("ConsumerKey is not configured"); {
private static string ConsumerSecret => Configuration[$"{ApiConfigSection}:ConsumerSecret"] ?? if (!_usePool || _overrideKey != null)
throw new InvalidOperationException("ConsumerSecret is not configured"); return _overrideKey!;
private static string OAuthToken => Configuration[$"{ApiConfigSection}:OAuthToken"] ??
throw new InvalidOperationException("OAuthToken is not configured"); if (_keys.Count == 1)
private static string OAuthTokenSecret => Configuration[$"{ApiConfigSection}:OAuthTokenSecret"] ?? return _keys[0];
throw new InvalidOperationException("OAuthTokenSecret is not configured");
int attempts = 0;
while (attempts < _keys.Count)
{
var key = _keys[_currentIndex % _keys.Count];
_currentIndex = (_currentIndex + 1) % _keys.Count;
if (IsKeyAvailable(key))
{
SaveState();
return key;
}
attempts++;
}
var earliest = _keys
.Select(k => new { Key = k, RetryUntil = GetRetryUntil(k) })
.OrderBy(x => x.RetryUntil)
.First();
Console.WriteLine($"[Pool] All keys rate-limited, using earliest: Key#{earliest.Key.KeyNumber} ({earliest.Key.SectionName}, clears in {Math.Max(0, earliest.RetryUntil - DateTimeOffset.UtcNow.ToUnixTimeSeconds())}s)");
var chosen = earliest.Key;
_currentIndex = (_keys.IndexOf(chosen) + 1) % _keys.Count;
SaveState();
return chosen;
}
private static bool IsKeyAvailable(ApiKeyConfig key)
{
var retryUntil = GetRetryUntil(key);
return retryUntil <= DateTimeOffset.UtcNow.ToUnixTimeSeconds();
}
private static long GetRetryUntil(ApiKeyConfig key)
{
using var conn = new System.Data.SQLite.SQLiteConnection("Data Source=" + _dbPath);
conn.Open();
using var cmd = new System.Data.SQLite.SQLiteCommand(
"SELECT RetryUntil FROM ApiKeyPoolState WHERE KeyName = @name", conn);
cmd.Parameters.AddWithValue("@name", key.SectionName);
var result = cmd.ExecuteScalar();
return result != null ? Convert.ToInt64(result) : 0;
}
public static void MarkRateLimited(ApiKeyConfig key, int retryInSeconds)
{
var retryUntil = DateTimeOffset.UtcNow.ToUnixTimeSeconds() + retryInSeconds;
using var conn = new System.Data.SQLite.SQLiteConnection("Data Source=" + _dbPath);
conn.Open();
using var cmd = new System.Data.SQLite.SQLiteCommand(
"INSERT OR REPLACE INTO ApiKeyPoolState (KeyName, RetryUntil) VALUES (@name, @until)", conn);
cmd.Parameters.AddWithValue("@name", key.SectionName);
cmd.Parameters.AddWithValue("@until", retryUntil);
cmd.ExecuteNonQuery();
Console.WriteLine($"[Pool] Key#{key.KeyNumber} ({key.SectionName}) rate-limited, retry in {retryInSeconds}s (until {DateTimeOffset.FromUnixTimeSeconds(retryUntil).LocalDateTime:HH:mm:ss})");
}
public static void MarkAvailable(ApiKeyConfig key)
{
using var conn = new System.Data.SQLite.SQLiteConnection("Data Source=" + _dbPath);
conn.Open();
using var cmd = new System.Data.SQLite.SQLiteCommand(
"INSERT OR REPLACE INTO ApiKeyPoolState (KeyName, RetryUntil) VALUES (@name, 0)", conn);
cmd.Parameters.AddWithValue("@name", key.SectionName);
cmd.ExecuteNonQuery();
}
private static void SaveState()
{
using var conn = new System.Data.SQLite.SQLiteConnection("Data Source=" + _dbPath);
conn.Open();
using var cmd = new System.Data.SQLite.SQLiteCommand(
"INSERT OR REPLACE INTO ApiKeyPoolMeta (Id, LastIndex) VALUES (1, @idx)", conn);
cmd.Parameters.AddWithValue("@idx", _currentIndex);
cmd.ExecuteNonQuery();
}
}
internal class APIAccess
{
public static string Blog { get; set; } = string.Empty;
public static void SetApiConfigSection(string sectionName)
{
if (!ApiKeyPool.IsPoolActive)
Console.WriteLine($"Using API settings section: {sectionName}");
}
private static RestClient BuildClient(ApiKeyConfig key, string url)
{
var client = new RestClient(url);
var oAuth1 = OAuth1Authenticator.ForAccessToken(
consumerKey: key.ConsumerKey,
consumerSecret: key.ConsumerSecret,
token: key.OAuthToken,
tokenSecret: key.OAuthTokenSecret,
OAuthSignatureMethod.HmacSha1);
client.Authenticator = oAuth1;
return client;
}
private static string FormatKeyLabel(ApiKeyConfig key) => $"[Key#{key.KeyNumber}]";
private static int GetRetryDelaySecondsFromHeaders(IEnumerable<HeaderParameter>? headers) private static int GetRetryDelaySecondsFromHeaders(IEnumerable<HeaderParameter>? headers)
{ {
@@ -1652,7 +1868,7 @@ namespace URLNotesGrabberCORE
return retryInSeconds > 0 ? retryInSeconds : 60; return retryInSeconds > 0 ? retryInSeconds : 60;
} }
public static async Task<Root> GrabNotes(string blog, long ID, string? timestamp = null) public static async Task<Root> GrabNotes(ApiKeyConfig key, string blog, long ID, string? timestamp = null)
{ {
var URL = "https://api.tumblr.com/v2/blog/[0].tumblr.com/notes?id=[1]&mode=all"; var URL = "https://api.tumblr.com/v2/blog/[0].tumblr.com/notes?id=[1]&mode=all";
URL = URL.Replace("[0]", blog).Replace("[1]", ID.ToString()); URL = URL.Replace("[0]", blog).Replace("[1]", ID.ToString());
@@ -1662,206 +1878,171 @@ namespace URLNotesGrabberCORE
await Task.Delay(100); await Task.Delay(100);
} }
// Create a new RestClient for each request to ensure fresh OAuth signatures using (var client = BuildClient(key, URL))
using (var client = new RestClient(URL))
{ {
var oAuth1 = OAuth1Authenticator.ForAccessToken(consumerKey: ConsumerKey,
consumerSecret: ConsumerSecret,
token: OAuthToken,
tokenSecret: OAuthTokenSecret,
OAuthSignatureMethod.HmacSha1
);
client.Authenticator = oAuth1;
var request = new RestRequest(URL, Method.Get);
var response = await client.ExecuteAsync(request);
var myJsonResponse = response.Content ?? string.Empty;
Console.WriteLine($"{timestamp}\t{DateTime.Now}\t{DataAccess.UpdateAPICount()}");
var myDeserializedClass = new Root();
try
{
var deserializedResult = JsonConvert.DeserializeObject<Root>(myJsonResponse);
if (deserializedResult != null)
{
myDeserializedClass = deserializedResult;
myDeserializedClass.rawJson = myJsonResponse;
if (myDeserializedClass.meta != null && myDeserializedClass.meta.status == 404)
{
myDeserializedClass.statusCode = "NotFound";
}
// If the response JSON indicates a 429 (Too Many Requests) via meta.status or message,
// treat it like a rate-limited response and attempt to read Retry headers.
bool metaIndicatesRateLimit = myDeserializedClass.meta != null && myDeserializedClass.meta.status == 429;
bool metaMsgIndicatesRateLimit = myDeserializedClass.meta != null && !string.IsNullOrEmpty(myDeserializedClass.meta.msg) && myDeserializedClass.meta.msg.IndexOf("Too Many", StringComparison.OrdinalIgnoreCase) >= 0;
if (metaIndicatesRateLimit || metaMsgIndicatesRateLimit || (response != null && (response.StatusDescription?.IndexOf("Too Many", StringComparison.OrdinalIgnoreCase) >= 0 || response.StatusCode == System.Net.HttpStatusCode.TooManyRequests)))
{
// populate retryInSeconds from headers if possible
if (response?.Headers != null)
{
bool checkResetLocal = false;
foreach (var header in response.Headers)
{
string? headerName = header?.Name;
string? headerValue = header?.Value?.ToString();
if (string.IsNullOrEmpty(headerName) || string.IsNullOrEmpty(headerValue))
continue;
if (string.Equals(headerName, "Retry-After", StringComparison.OrdinalIgnoreCase))
{
if (int.TryParse(headerValue, out int retrySecs))
myDeserializedClass.retryInSeconds = Math.Max(myDeserializedClass.retryInSeconds, retrySecs);
else if (DateTimeOffset.TryParse(headerValue, out var dto))
myDeserializedClass.retryInSeconds = Math.Max(myDeserializedClass.retryInSeconds, (int)Math.Max(0, (dto - DateTimeOffset.UtcNow).TotalSeconds));
}
if (headerName.IndexOf("X-RateLimit-Reset", StringComparison.OrdinalIgnoreCase) >= 0 && long.TryParse(headerValue, out long epoch))
{
var secs = (int)Math.Max(0, epoch - DateTimeOffset.UtcNow.ToUnixTimeSeconds());
myDeserializedClass.retryInSeconds = Math.Max(myDeserializedClass.retryInSeconds, secs);
}
if (headerName.Contains("Remaining", StringComparison.OrdinalIgnoreCase) && headerValue == "0")
checkResetLocal = true;
if (checkResetLocal && headerName.IndexOf("Reset", StringComparison.OrdinalIgnoreCase) >= 0)
{
if (int.TryParse(headerValue, out int resetValue))
myDeserializedClass.retryInSeconds = Math.Max(myDeserializedClass.retryInSeconds, resetValue);
else if (long.TryParse(headerValue, out long epochVal))
myDeserializedClass.retryInSeconds = Math.Max(myDeserializedClass.retryInSeconds, (int)Math.Max(0, epochVal - DateTimeOffset.UtcNow.ToUnixTimeSeconds()));
}
}
}
// surface rate-limit status back to caller
myDeserializedClass.statusCode = "TooManyRequests";
}
}
}
catch (Exception ex)
{
Console.WriteLine($"Failed JSON: {myJsonResponse}");
Console.WriteLine(ex.ToString());
if (!response.IsSuccessful)
{
// Response.StatusCode may be null with some RestSharp responses.
// Guard it and still attempt to extract retry time from headers (Retry-After, Remaining/Reset, X-RateLimit-Reset)
string? statusStr = null;
try { statusStr = response != null ? response.StatusCode.ToString() : null; } catch { statusStr = null; }
Console.WriteLine($"{statusStr}\t{response?.StatusDescription}");
// Only set the status if we actually have one; otherwise leave existing value alone (may be null)
if (!string.IsNullOrEmpty(statusStr))
myDeserializedClass.statusCode = statusStr;
bool checkReset = false;
if ((myDeserializedClass.statusCode != "NotFound" || myDeserializedClass.retryInSeconds > 0) && response.Headers != null)
{
bool foundRateLimitHeader = false;
foreach (var header in response.Headers)
{
if (header.Name != null && header.Value != null)
{
Console.WriteLine($"{header.Name} - {header.Value}");
// Common header patterns used by APIs to indicate retry times:
// - Retry-After: either seconds or HTTP date
// - X-RateLimit-Reset: often seconds since epoch
// - <something>Reset (after Remaining==0): seconds
var headerValue = header.Value?.ToString();
if (!string.IsNullOrEmpty(headerValue))
{
// 1) Retry-After header (seconds or date)
if (string.Equals(header.Name, "Retry-After", StringComparison.OrdinalIgnoreCase))
{
if (int.TryParse(headerValue, out int retrySecs))
{
if (myDeserializedClass.retryInSeconds < retrySecs)
myDeserializedClass.retryInSeconds = retrySecs;
}
else if (DateTimeOffset.TryParse(headerValue, out DateTimeOffset dto))
{
var secs = (int)Math.Max(0, (dto - DateTimeOffset.UtcNow).TotalSeconds);
if (myDeserializedClass.retryInSeconds < secs)
myDeserializedClass.retryInSeconds = secs;
}
}
// 2) Common 'Reset' header: numeric seconds or epoch seconds
if (checkReset && header.Name.Contains("Reset", StringComparison.OrdinalIgnoreCase))
{
if (int.TryParse(headerValue, out int resetValue))
{
if (myDeserializedClass.retryInSeconds < resetValue)
myDeserializedClass.retryInSeconds = resetValue;
}
else if (long.TryParse(headerValue, out long epochVal))
{
// treat as epoch seconds -> compute secs until that epoch
var secs = (int)Math.Max(0, epochVal - DateTimeOffset.UtcNow.ToUnixTimeSeconds());
if (myDeserializedClass.retryInSeconds < secs)
myDeserializedClass.retryInSeconds = secs;
}
}
// 3) X-RateLimit-Reset header: often epoch seconds
if (header.Name.IndexOf("X-RateLimit-Reset", StringComparison.OrdinalIgnoreCase) >= 0)
{
if (long.TryParse(headerValue, out long epoch))
{
var secs = (int)Math.Max(0, epoch - DateTimeOffset.UtcNow.ToUnixTimeSeconds());
if (myDeserializedClass.retryInSeconds < secs)
myDeserializedClass.retryInSeconds = secs;
}
foundRateLimitHeader = true;
}
}
if (header.Name.Contains("Remaining", StringComparison.OrdinalIgnoreCase) && header.Value.ToString() == "0")
checkReset = true;
else
checkReset = false;
}
// If we detected rate-limit related headers (Retry-After / X-RateLimit-Reset / Remaining/Reset)
// or the HTTP status indicates 429, surface it.
if (foundRateLimitHeader || (response != null && response.StatusCode == System.Net.HttpStatusCode.TooManyRequests))
{
myDeserializedClass.statusCode = "TooManyRequests";
}
}
}
}
}
return myDeserializedClass;
}
}
public static async Task<PostsRoot> GrabPostWithReplies(string blog, long postID, long timestamp)
{
var URL = $"https://api.tumblr.com/v2/blog/{Uri.EscapeDataString(blog)}.tumblr.com/posts?id={postID}&notes_info=true&before_timestamp={timestamp}";
// Create a new RestClient for each request to ensure fresh OAuth signatures
using (var client = new RestClient(URL))
{
var oAuth1 = OAuth1Authenticator.ForAccessToken(consumerKey: ConsumerKey,
consumerSecret: ConsumerSecret,
token: OAuthToken,
tokenSecret: OAuthTokenSecret,
OAuthSignatureMethod.HmacSha1
);
client.Authenticator = oAuth1;
var request = new RestRequest(URL, Method.Get); var request = new RestRequest(URL, Method.Get);
var response = await client.ExecuteAsync(request); var response = await client.ExecuteAsync(request);
var myJsonResponse = response.Content ?? string.Empty; var myJsonResponse = response.Content ?? string.Empty;
Console.WriteLine($"[Reply API] {DateTime.Now}\t{DataAccess.UpdateAPICount()}"); Console.WriteLine($"{FormatKeyLabel(key)} {timestamp}\t{DateTime.Now}\t{DataAccess.UpdateAPICount()}");
var myDeserializedClass = new Root();
try
{
var deserializedResult = JsonConvert.DeserializeObject<Root>(myJsonResponse);
if (deserializedResult != null)
{
myDeserializedClass = deserializedResult;
myDeserializedClass.rawJson = myJsonResponse;
if (myDeserializedClass.meta != null && myDeserializedClass.meta.status == 404)
{
myDeserializedClass.statusCode = "NotFound";
}
bool metaIndicatesRateLimit = myDeserializedClass.meta != null && myDeserializedClass.meta.status == 429;
bool metaMsgIndicatesRateLimit = myDeserializedClass.meta != null && !string.IsNullOrEmpty(myDeserializedClass.meta.msg) && myDeserializedClass.meta.msg.IndexOf("Too Many", StringComparison.OrdinalIgnoreCase) >= 0;
if (metaIndicatesRateLimit || metaMsgIndicatesRateLimit || (response != null && (response.StatusDescription?.IndexOf("Too Many", StringComparison.OrdinalIgnoreCase) >= 0 || response.StatusCode == System.Net.HttpStatusCode.TooManyRequests)))
{
if (response?.Headers != null)
{
bool checkResetLocal = false;
foreach (var header in response.Headers)
{
string? headerName = header?.Name;
string? headerValue = header?.Value?.ToString();
if (string.IsNullOrEmpty(headerName) || string.IsNullOrEmpty(headerValue))
continue;
if (string.Equals(headerName, "Retry-After", StringComparison.OrdinalIgnoreCase))
{
if (int.TryParse(headerValue, out int retrySecs))
myDeserializedClass.retryInSeconds = Math.Max(myDeserializedClass.retryInSeconds, retrySecs);
else if (DateTimeOffset.TryParse(headerValue, out var dto))
myDeserializedClass.retryInSeconds = Math.Max(myDeserializedClass.retryInSeconds, (int)Math.Max(0, (dto - DateTimeOffset.UtcNow).TotalSeconds));
}
if (headerName.IndexOf("X-RateLimit-Reset", StringComparison.OrdinalIgnoreCase) >= 0 && long.TryParse(headerValue, out long epoch))
{
var secs = (int)Math.Max(0, epoch - DateTimeOffset.UtcNow.ToUnixTimeSeconds());
myDeserializedClass.retryInSeconds = Math.Max(myDeserializedClass.retryInSeconds, secs);
}
if (headerName.Contains("Remaining", StringComparison.OrdinalIgnoreCase) && headerValue == "0")
checkResetLocal = true;
if (checkResetLocal && headerName.IndexOf("Reset", StringComparison.OrdinalIgnoreCase) >= 0)
{
if (int.TryParse(headerValue, out int resetValue))
myDeserializedClass.retryInSeconds = Math.Max(myDeserializedClass.retryInSeconds, resetValue);
else if (long.TryParse(headerValue, out long epochVal))
myDeserializedClass.retryInSeconds = Math.Max(myDeserializedClass.retryInSeconds, (int)Math.Max(0, epochVal - DateTimeOffset.UtcNow.ToUnixTimeSeconds()));
}
}
}
myDeserializedClass.statusCode = "TooManyRequests";
}
}
}
catch (Exception ex)
{
Console.WriteLine($"Failed JSON: {myJsonResponse}");
Console.WriteLine(ex.ToString());
if (!response.IsSuccessful)
{
string? statusStr = null;
try { statusStr = response != null ? response.StatusCode.ToString() : null; } catch { statusStr = null; }
Console.WriteLine($"{statusStr}\t{response?.StatusDescription}");
if (!string.IsNullOrEmpty(statusStr))
myDeserializedClass.statusCode = statusStr;
bool checkReset = false;
if ((myDeserializedClass.statusCode != "NotFound" || myDeserializedClass.retryInSeconds > 0) && response.Headers != null)
{
bool foundRateLimitHeader = false;
foreach (var header in response.Headers)
{
if (header.Name != null && header.Value != null)
{
Console.WriteLine($"{header.Name} - {header.Value}");
var headerValue = header.Value?.ToString();
if (!string.IsNullOrEmpty(headerValue))
{
if (string.Equals(header.Name, "Retry-After", StringComparison.OrdinalIgnoreCase))
{
if (int.TryParse(headerValue, out int retrySecs))
{
if (myDeserializedClass.retryInSeconds < retrySecs)
myDeserializedClass.retryInSeconds = retrySecs;
}
else if (DateTimeOffset.TryParse(headerValue, out DateTimeOffset dto))
{
var secs = (int)Math.Max(0, (dto - DateTimeOffset.UtcNow).TotalSeconds);
if (myDeserializedClass.retryInSeconds < secs)
myDeserializedClass.retryInSeconds = secs;
}
}
if (checkReset && header.Name.Contains("Reset", StringComparison.OrdinalIgnoreCase))
{
if (int.TryParse(headerValue, out int resetValue))
{
if (myDeserializedClass.retryInSeconds < resetValue)
myDeserializedClass.retryInSeconds = resetValue;
}
else if (long.TryParse(headerValue, out long epochVal))
{
var secs = (int)Math.Max(0, epochVal - DateTimeOffset.UtcNow.ToUnixTimeSeconds());
if (myDeserializedClass.retryInSeconds < secs)
myDeserializedClass.retryInSeconds = secs;
}
}
if (header.Name.IndexOf("X-RateLimit-Reset", StringComparison.OrdinalIgnoreCase) >= 0)
{
if (long.TryParse(headerValue, out long epoch))
{
var secs = (int)Math.Max(0, epoch - DateTimeOffset.UtcNow.ToUnixTimeSeconds());
if (myDeserializedClass.retryInSeconds < secs)
myDeserializedClass.retryInSeconds = secs;
}
foundRateLimitHeader = true;
}
}
if (header.Name.Contains("Remaining", StringComparison.OrdinalIgnoreCase) && header.Value.ToString() == "0")
checkReset = true;
else
checkReset = false;
}
if (foundRateLimitHeader || (response != null && response.StatusCode == System.Net.HttpStatusCode.TooManyRequests))
{
myDeserializedClass.statusCode = "TooManyRequests";
}
}
}
}
}
return myDeserializedClass;
}
}
public static async Task<PostsRoot> GrabPostWithReplies(ApiKeyConfig key, string blog, long postID, long timestamp)
{
var URL = $"https://api.tumblr.com/v2/blog/{Uri.EscapeDataString(blog)}.tumblr.com/posts?id={postID}&notes_info=true&before_timestamp={timestamp}";
using (var client = BuildClient(key, URL))
{
var request = new RestRequest(URL, Method.Get);
var response = await client.ExecuteAsync(request);
var myJsonResponse = response.Content ?? string.Empty;
Console.WriteLine($"[Reply API] {FormatKeyLabel(key)} {DateTime.Now}\t{DataAccess.UpdateAPICount()}");
var myDeserializedClass = new PostsRoot(); var myDeserializedClass = new PostsRoot();
// Check if response is successful and contains JSON // Check if response is successful and contains JSON
@@ -1905,7 +2086,7 @@ namespace URLNotesGrabberCORE
} }
} }
public static async Task<LikesRoot> GrabLikes(string blog, long beforeTimestamp = 0) public static async Task<LikesRoot> GrabLikes(ApiKeyConfig key, string blog, long beforeTimestamp = 0)
{ {
var URL = $"https://api.tumblr.com/v2/blog/{Uri.EscapeDataString(blog)}.tumblr.com/likes?npf=false&reblog_info=true"; var URL = $"https://api.tumblr.com/v2/blog/{Uri.EscapeDataString(blog)}.tumblr.com/likes?npf=false&reblog_info=true";
if (beforeTimestamp > 0) if (beforeTimestamp > 0)
@@ -1913,21 +2094,13 @@ namespace URLNotesGrabberCORE
URL += $"&before={beforeTimestamp}"; URL += $"&before={beforeTimestamp}";
} }
using (var client = new RestClient(URL)) using (var client = BuildClient(key, URL))
{ {
var oAuth1 = OAuth1Authenticator.ForAccessToken(consumerKey: ConsumerKey,
consumerSecret: ConsumerSecret,
token: OAuthToken,
tokenSecret: OAuthTokenSecret,
OAuthSignatureMethod.HmacSha1
);
client.Authenticator = oAuth1;
var request = new RestRequest(URL, Method.Get); var request = new RestRequest(URL, Method.Get);
var response = await client.ExecuteAsync(request); var response = await client.ExecuteAsync(request);
var myJsonResponse = response.Content ?? string.Empty; var myJsonResponse = response.Content ?? string.Empty;
Console.WriteLine($"[Likes API] {DateTime.Now}\t{DataAccess.UpdateAPICount()}"); Console.WriteLine($"[Likes API] {FormatKeyLabel(key)} {DateTime.Now}\t{DataAccess.UpdateAPICount()}");
var myDeserializedClass = new LikesRoot(); var myDeserializedClass = new LikesRoot();
// Check if response is successful and contains JSON // Check if response is successful and contains JSON
+62 -23
View File
@@ -23,6 +23,7 @@ namespace URLNotesGrabberCORE
var settings = config.GetSection("appSettings"); var settings = config.GetSection("appSettings");
string apiSectionName = "TumblrApi"; string apiSectionName = "TumblrApi";
bool apiExplicitlySet = false;
string startFromBlogName = string.Empty; string startFromBlogName = string.Empty;
List<string> filteredArgs = new List<string>(); List<string> filteredArgs = new List<string>();
for (int i = 0; i < args.Length; i++) for (int i = 0; i < args.Length; i++)
@@ -31,6 +32,7 @@ namespace URLNotesGrabberCORE
string.Equals(args[i], "--api3", StringComparison.OrdinalIgnoreCase)) string.Equals(args[i], "--api3", StringComparison.OrdinalIgnoreCase))
{ {
apiSectionName = "TumblrApi3"; apiSectionName = "TumblrApi3";
apiExplicitlySet = true;
continue; continue;
} }
@@ -38,6 +40,7 @@ namespace URLNotesGrabberCORE
string.Equals(args[i], "--api4", StringComparison.OrdinalIgnoreCase)) string.Equals(args[i], "--api4", StringComparison.OrdinalIgnoreCase))
{ {
apiSectionName = "TumblrApi4"; apiSectionName = "TumblrApi4";
apiExplicitlySet = true;
continue; continue;
} }
@@ -47,6 +50,7 @@ namespace URLNotesGrabberCORE
if (i + 1 < args.Length && !string.IsNullOrWhiteSpace(args[i + 1])) if (i + 1 < args.Length && !string.IsNullOrWhiteSpace(args[i + 1]))
{ {
apiSectionName = args[i + 1].Trim(); apiSectionName = args[i + 1].Trim();
apiExplicitlySet = true;
i++; i++;
} }
else else
@@ -75,7 +79,12 @@ namespace URLNotesGrabberCORE
} }
args = filteredArgs.ToArray(); args = filteredArgs.ToArray();
APIAccess.SetApiConfigSection(apiSectionName);
string? dbPath = config["appSettings:PathDB"];
if (string.IsNullOrWhiteSpace(dbPath))
dbPath = Path.Combine(Directory.GetCurrentDirectory(), "..", "..", "..", "tl.db");
ApiKeyPool.Initialize(config, dbPath, apiExplicitlySet ? apiSectionName : null);
// Setup Dual Logging // Setup Dual Logging
bool enableFileLogging = settings.GetValue("EnableFileLogging", true); bool enableFileLogging = settings.GetValue("EnableFileLogging", true);
@@ -439,9 +448,17 @@ namespace URLNotesGrabberCORE
try try
{ {
Console.WriteLine($"[Reply Text] Fetching reply text for {blogName}/{postID}/{timestamp}"); Console.WriteLine($"[Reply Text] Fetching reply text for {blogName}/{postID}/{timestamp}");
// Add 2-second delay before API call to avoid server-side rate limiting
await Task.Delay(2000); await Task.Delay(2000);
var postsResponse = await APIAccess.GrabPostWithReplies(blogName, postID, timestamp); var key = ApiKeyPool.GetCurrentKey();
var postsResponse = await APIAccess.GrabPostWithReplies(key, blogName, postID, timestamp);
if (postsResponse?.statusCode == "TooManyRequests")
{
int retry = postsResponse.retryInSeconds > 0 ? postsResponse.retryInSeconds : 60;
ApiKeyPool.MarkRateLimited(key, retry);
Console.WriteLine($"[Reply Text] Rate limited, will retry with next key");
return;
}
if (postsResponse?.response?.posts == null || postsResponse.response.posts.Count == 0) if (postsResponse?.response?.posts == null || postsResponse.response.posts.Count == 0)
{ {
@@ -677,24 +694,16 @@ namespace URLNotesGrabberCORE
if (!lease.IsAcquired) if (!lease.IsAcquired)
{ {
Console.WriteLine("!@@@@@@@ - Rate Limited Exceeded: No Lease Available"); Console.WriteLine("!@@@@@@@ - Rate Limited Exceeded: No Lease Available");
return; // Might want to sleep instead, but this matches other functions return;
} }
var response = await APIAccess.GrabLikes(blogName, cursor); var key = ApiKeyPool.GetCurrentKey();
var response = await APIAccess.GrabLikes(key, blogName, cursor);
if (response?.statusCode == "NotFound" || (response?.meta != null && response.meta.status == 404)) if (response?.statusCode == "TooManyRequests")
{ {
Console.WriteLine($"API returned 404 Not Found for {blogName} Likes"); int retry = response.retryInSeconds > 0 ? response.retryInSeconds : 60;
// Set pulled = 1 to skip in future ApiKeyPool.MarkRateLimited(key, retry);
DataAccess.UpdateBlogLikesStatus(blogName, 1, cursor);
break;
}
if (response == null || response.statusCode == "TooManyRequests")
{
int retry = response?.retryInSeconds ?? 60;
if (retry <= 0)
retry = 60;
int remaining = retry; int remaining = retry;
DateTime retryUntil = DateTime.Now.AddSeconds(retry); DateTime retryUntil = DateTime.Now.AddSeconds(retry);
@@ -705,7 +714,17 @@ namespace URLNotesGrabberCORE
Thread.Sleep(sleepSeconds * 1000); Thread.Sleep(sleepSeconds * 1000);
remaining -= sleepSeconds; remaining -= sleepSeconds;
} }
continue; // Retry same cursor continue;
}
if (response.meta?.status != 429)
ApiKeyPool.MarkAvailable(key);
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);
break;
} }
if (response?.response?.liked_posts == null || response.response.liked_posts.Count == 0) if (response?.response?.liked_posts == null || response.response.liked_posts.Count == 0)
@@ -879,16 +898,25 @@ namespace URLNotesGrabberCORE
int APICount = DataAccess.GetAPICount(); int APICount = DataAccess.GetAPICount();
Console.WriteLine($"{post.Item1}\t{post.Item2}\t{DateTime.Now}\t{APICount}"); Console.WriteLine($"{post.Item1}\t{post.Item2}\t{DateTime.Now}\t{APICount}");
//Thread.Sleep(3000);
var allNotes = new List<dynamic>(); var allNotes = new List<dynamic>();
int page = 1; int page = 1;
string beforeTimestamp = post.Item3.ToString(); string beforeTimestamp = post.Item3.ToString();
bool hasReplies = false; bool hasReplies = false;
const int maxPages = 500; const int maxPages = 500;
var response = APIAccess.GrabNotes(post.Item1, post.Item2, beforeTimestamp).GetAwaiter().GetResult(); var key = ApiKeyPool.GetCurrentKey();
var response = await APIAccess.GrabNotes(key, post.Item1, post.Item2, beforeTimestamp);
if (response.statusCode == "TooManyRequests")
{
int retry = response.retryInSeconds > 0 ? response.retryInSeconds : 60;
ApiKeyPool.MarkRateLimited(key, retry);
return "TooManyRequests";
}
if (response.meta?.status != 429)
ApiKeyPool.MarkAvailable(key);
// Handle 404 and error codes
if (IsNotFound(response)) if (IsNotFound(response))
{ {
Console.WriteLine($"API returned 404 Not Found for {post.Item1}/{post.Item2}"); Console.WriteLine($"API returned 404 Not Found for {post.Item1}/{post.Item2}");
@@ -903,6 +931,8 @@ namespace URLNotesGrabberCORE
} }
if (response.statusCode == "TooManyRequests") if (response.statusCode == "TooManyRequests")
{ {
int retry = response.retryInSeconds > 0 ? response.retryInSeconds : 60;
ApiKeyPool.MarkRateLimited(key, retry);
for (int s = 0; s <= response.retryInSeconds; s += 60) for (int s = 0; s <= response.retryInSeconds; s += 60)
{ {
Console.WriteLine("Sleeping for {0} more seconds, until {1}", response.retryInSeconds - s, DateTime.Now.AddSeconds(response.retryInSeconds - s).ToShortTimeString()); Console.WriteLine("Sleeping for {0} more seconds, until {1}", response.retryInSeconds - s, DateTime.Now.AddSeconds(response.retryInSeconds - s).ToShortTimeString());
@@ -959,8 +989,17 @@ namespace URLNotesGrabberCORE
break; break;
} }
// Fetch next page key = ApiKeyPool.GetCurrentKey();
response = APIAccess.GrabNotes(post.Item1, post.Item2, beforeTimestamp).GetAwaiter().GetResult(); response = await APIAccess.GrabNotes(key, post.Item1, post.Item2, beforeTimestamp);
if (response.statusCode == "TooManyRequests")
{
int retry = response.retryInSeconds > 0 ? response.retryInSeconds : 60;
ApiKeyPool.MarkRateLimited(key, retry);
return "TooManyRequests";
}
if (response.meta?.status != 429)
ApiKeyPool.MarkAvailable(key);
if (IsNotFound(response)) if (IsNotFound(response))
{ {
Console.WriteLine($"API returned 404 Not Found during pagination for {post.Item1}/{post.Item2}"); Console.WriteLine($"API returned 404 Not Found during pagination for {post.Item1}/{post.Item2}");
+6 -3
View File
@@ -17,18 +17,21 @@
"ConsumerKey": "PtsBCGumcsgihyynxUh8b47Jfmi7uXhEIOU46bmhdsUSJ5mEP3", "ConsumerKey": "PtsBCGumcsgihyynxUh8b47Jfmi7uXhEIOU46bmhdsUSJ5mEP3",
"ConsumerSecret": "sA8BwNVTVKqBRRJmHbAD6NuyKPJ3bb9cei2bYhMgqT8cLX8tSG", "ConsumerSecret": "sA8BwNVTVKqBRRJmHbAD6NuyKPJ3bb9cei2bYhMgqT8cLX8tSG",
"OAuthToken": "HPJI6IijHoKN6WzBumG7KjS7g01iCu07jQsulpueKbWN1ZJ35J", "OAuthToken": "HPJI6IijHoKN6WzBumG7KjS7g01iCu07jQsulpueKbWN1ZJ35J",
"OAuthTokenSecret": "ajkN0Z1kKrsJaIXZEDI8zLsjRSQxFkfgO1i5k78FuQYCQjSY7w" "OAuthTokenSecret": "ajkN0Z1kKrsJaIXZEDI8zLsjRSQxFkfgO1i5k78FuQYCQjSY7w",
"PoolEnabled": true
}, },
"TumblrApi3": { "TumblrApi3": {
"ConsumerKey": "Jmoh13AS9hKBYsf939uENQsiWUuZJLFT3do4YK9P0bpspVuxkH", "ConsumerKey": "Jmoh13AS9hKBYsf939uENQsiWUuZJLFT3do4YK9P0bpspVuxkH",
"ConsumerSecret": "8LcHjuqpOS9gZZPaML1joT330w5NqyGdreSqSBazdAWCADzcms", "ConsumerSecret": "8LcHjuqpOS9gZZPaML1joT330w5NqyGdreSqSBazdAWCADzcms",
"OAuthToken": "Gm2esFTeb5LKsb4A2YJGrF2Udycfc0AF8afof2qNGyzNSq1qWM", "OAuthToken": "Gm2esFTeb5LKsb4A2YJGrF2Udycfc0AF8afof2qNGyzNSq1qWM",
"OAuthTokenSecret": "Kfokor80s5fVue91OmNHidCdILZ9AnHtRplrct2P6Q75jgNTLa" "OAuthTokenSecret": "Kfokor80s5fVue91OmNHidCdILZ9AnHtRplrct2P6Q75jgNTLa",
"PoolEnabled": false
}, },
"TumblrApi4": { "TumblrApi4": {
"ConsumerKey": "gOuydIEENkRmEvvuf57R69yPRb39FC0Egb9p6ntxWuCuFzlsV3", "ConsumerKey": "gOuydIEENkRmEvvuf57R69yPRb39FC0Egb9p6ntxWuCuFzlsV3",
"ConsumerSecret": "0my913slXAHgra4mEEYV98stJ3wXGjPTWJeviiZXwJ2AqzYtIF ", "ConsumerSecret": "0my913slXAHgra4mEEYV98stJ3wXGjPTWJeviiZXwJ2AqzYtIF ",
"OAuthToken": "JHk08YufSrsetTR1Ekuh10cPOzC6rjqU5WwVu3uV4vvLzp0w8w", "OAuthToken": "JHk08YufSrsetTR1Ekuh10cPOzC6rjqU5WwVu3uV4vvLzp0w8w",
"OAuthTokenSecret": "1oryr4WMBxuDrt4vL4sJQs5qNagkH3KxwIf61M8SfvklTrhHSb" "OAuthTokenSecret": "1oryr4WMBxuDrt4vL4sJQs5qNagkH3KxwIf61M8SfvklTrhHSb",
"PoolEnabled": true
} }
} }