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:
+417
-244
@@ -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}¬es_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}¬es_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
|
||||||
|
|||||||
@@ -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}");
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user