feat: add API key color assignment, auto-persist, AGENTS.md

This commit is contained in:
jim
2026-05-04 10:33:46 -05:00
parent 34e5745e99
commit 5e62b58261
3 changed files with 213 additions and 32 deletions
+45
View File
@@ -0,0 +1,45 @@
# AGENTS.md
## Project Overview
- **Primary Language**: C# (.NET 8 Console Application)
- **Key Libraries**: RestSharp, Newtonsoft.Json, System.Data.SQLite, Microsoft.Extensions.Configuration
- **Purpose**: Tumblr API data harvester for collecting notes, posts, likes, and replies, storing results in SQLite.
## Architectural Patterns
- CLI entry point in `Program.cs` with workflow orchestration
- `DataAccess.cs`: Database operations, `ApiKeyPool` (API key management), `APIAccess` (Tumblr client)
- `ResponseNotes.cs`: Tumblr API response models
- Round-robin API key rotation with rate-limit tracking
- Automatic console color assignment per API key for output differentiation
## Developer Guidelines
### Code Formatting
- 4-space indentation, no tabs, match existing C# style
- PascalCase for public members, camelCase for locals
- Minimize code comments unless explicitly requested
- Use only existing project libraries; no new dependencies without confirmation
- Match accessibility modifiers (`public` for models, `internal` for helpers)
### Error Handling
- Wrap file/network operations in `try-catch`
- Log non-critical errors (e.g., config write failures) with `[Warning]` prefix
- Preserve console color state: use save/restore pattern for temporary color changes
- API rate limits must use `ApiKeyPool.MarkRateLimited()`/`MarkAvailable()`
### Testing
- No existing test suite; use xUnit if adding tests
- Test critical logic: `ApiKeyPool` init, color parsing, config persistence
- Avoid testing one-off CLI workflows
### Git Commit Messages
- Imperative mood ("Add feature" not "Added feature")
- Prefix with type: `feat:`, `fix:`, `chore:`, `docs:`
- Keep messages under 72 characters
- Never commit sensitive data (API keys/tokens)
### API Key Color Rules
- Unconfigured keys auto-assign colors from a preset palette
- Auto-assigned colors persist to `appsettings.json`
- All output for an active key uses its assigned color
- Temporary color changes (e.g., errors) must restore the key's color afterward
+151 -15
View File
@@ -14,6 +14,7 @@ using System.Collections;
using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Configuration;
using System.IO; using System.IO;
using System.Data; using System.Data;
using System.Text.Json;
namespace URLNotesGrabberCORE namespace URLNotesGrabberCORE
{ {
@@ -1556,6 +1557,8 @@ namespace URLNotesGrabberCORE
public string OAuthToken { get; set; } = string.Empty; public string OAuthToken { get; set; } = string.Empty;
public string OAuthTokenSecret { get; set; } = string.Empty; public string OAuthTokenSecret { get; set; } = string.Empty;
public bool PoolEnabled { get; set; } public bool PoolEnabled { get; set; }
public string ColorName { get; set; } = string.Empty;
public ConsoleColor ParsedColor { get; set; } = ConsoleColor.White;
} }
internal class ApiKeyPool internal class ApiKeyPool
@@ -1565,13 +1568,22 @@ namespace URLNotesGrabberCORE
private static bool _usePool = false; private static bool _usePool = false;
private static int _currentIndex = 0; private static int _currentIndex = 0;
private static string _dbPath = string.Empty; private static string _dbPath = string.Empty;
private static ApiKeyConfig? _activeKey = null;
private static readonly string[] DefaultAutoColors =
{
"Cyan", "Yellow", "Green", "Magenta", "Blue", "Red",
"DarkCyan", "DarkYellow", "DarkGreen", "DarkMagenta"
};
private static int _nextAutoColorIndex = 0;
private static string _configFilePath = string.Empty;
public static bool IsPoolActive => _usePool; public static bool IsPoolActive => _usePool;
public static List<ApiKeyConfig> Keys => _keys; public static List<ApiKeyConfig> Keys => _keys;
public static void Initialize(IConfiguration config, string? dbPath, string? overrideSection = null) public static void Initialize(IConfiguration config, string? dbPath, string configFilePath, string? overrideSection = null)
{ {
_dbPath = dbPath ?? "..\\..\\..\\tl.db"; _dbPath = dbPath ?? "..\\..\\..\\tl.db";
_configFilePath = configFilePath;
EnsureStateTableExists(); EnsureStateTableExists();
if (!string.IsNullOrEmpty(overrideSection)) if (!string.IsNullOrEmpty(overrideSection))
@@ -1580,6 +1592,9 @@ namespace URLNotesGrabberCORE
if (section["ConsumerKey"] == null) if (section["ConsumerKey"] == null)
throw new InvalidOperationException($"API section '{overrideSection}' not found in appsettings.json"); throw new InvalidOperationException($"API section '{overrideSection}' not found in appsettings.json");
var colorName = section["Color"] ?? string.Empty;
var parsedColor = ParseColor(colorName);
_overrideKey = new ApiKeyConfig _overrideKey = new ApiKeyConfig
{ {
SectionName = overrideSection, SectionName = overrideSection,
@@ -1588,10 +1603,12 @@ namespace URLNotesGrabberCORE
ConsumerSecret = section["ConsumerSecret"]!, ConsumerSecret = section["ConsumerSecret"]!,
OAuthToken = section["OAuthToken"]!, OAuthToken = section["OAuthToken"]!,
OAuthTokenSecret = section["OAuthTokenSecret"]!, OAuthTokenSecret = section["OAuthTokenSecret"]!,
PoolEnabled = true PoolEnabled = true,
ColorName = colorName,
ParsedColor = parsedColor
}; };
_usePool = false; _usePool = false;
Console.WriteLine($"[Pool] Single-key mode: {overrideSection}"); Console.WriteLine($"[Pool] Single-key mode: {overrideSection} (Color: {_overrideKey.ParsedColor})");
return; return;
} }
@@ -1601,6 +1618,8 @@ namespace URLNotesGrabberCORE
.Distinct() .Distinct()
.ToList(); .ToList();
var autoAssignedColors = new List<(string sectionName, string color)>();
int keyNum = 1; int keyNum = 1;
foreach (var sectionName in root.OrderBy(s => s)) foreach (var sectionName in root.OrderBy(s => s))
{ {
@@ -1610,6 +1629,16 @@ namespace URLNotesGrabberCORE
if (poolEnabled) if (poolEnabled)
{ {
var colorName = section["Color"] ?? string.Empty;
var parsedColor = ParseColor(colorName);
if (string.IsNullOrEmpty(colorName))
{
colorName = GetNextAutoColor();
parsedColor = ParseColor(colorName);
autoAssignedColors.Add((sectionName, colorName));
}
_keys.Add(new ApiKeyConfig _keys.Add(new ApiKeyConfig
{ {
SectionName = sectionName, SectionName = sectionName,
@@ -1618,7 +1647,9 @@ namespace URLNotesGrabberCORE
ConsumerSecret = section["ConsumerSecret"]!, ConsumerSecret = section["ConsumerSecret"]!,
OAuthToken = section["OAuthToken"]!, OAuthToken = section["OAuthToken"]!,
OAuthTokenSecret = section["OAuthTokenSecret"]!, OAuthTokenSecret = section["OAuthTokenSecret"]!,
PoolEnabled = true PoolEnabled = true,
ColorName = colorName,
ParsedColor = parsedColor
}); });
keyNum++; keyNum++;
} }
@@ -1629,6 +1660,16 @@ namespace URLNotesGrabberCORE
var fallback = config.GetSection("TumblrApi"); var fallback = config.GetSection("TumblrApi");
if (fallback["ConsumerKey"] != null) if (fallback["ConsumerKey"] != null)
{ {
var colorName = fallback["Color"] ?? string.Empty;
var parsedColor = ParseColor(colorName);
if (string.IsNullOrEmpty(colorName))
{
colorName = GetNextAutoColor();
parsedColor = ParseColor(colorName);
autoAssignedColors.Add(("TumblrApi", colorName));
}
_keys.Add(new ApiKeyConfig _keys.Add(new ApiKeyConfig
{ {
SectionName = "TumblrApi", SectionName = "TumblrApi",
@@ -1637,19 +1678,92 @@ namespace URLNotesGrabberCORE
ConsumerSecret = fallback["ConsumerSecret"]!, ConsumerSecret = fallback["ConsumerSecret"]!,
OAuthToken = fallback["OAuthToken"]!, OAuthToken = fallback["OAuthToken"]!,
OAuthTokenSecret = fallback["OAuthTokenSecret"]!, OAuthTokenSecret = fallback["OAuthTokenSecret"]!,
PoolEnabled = true PoolEnabled = true,
ColorName = colorName,
ParsedColor = parsedColor
}); });
Console.WriteLine("[Pool] No keys with PoolEnabled, falling back to TumblrApi"); Console.WriteLine("[Pool] No keys with PoolEnabled, falling back to TumblrApi");
} }
} }
if (autoAssignedColors.Count > 0)
{
PersistAutoAssignedColors(autoAssignedColors);
}
_usePool = true; _usePool = true;
LoadState(); LoadState();
var mapping = string.Join(", ", _keys.Select(k => $"Key#{k.KeyNumber}={k.SectionName}")); var mapping = string.Join(", ", _keys.Select(k => $"Key#{k.KeyNumber}={k.SectionName} (Color: {k.ParsedColor})"));
Console.WriteLine($"[Pool] Active keys: {mapping}"); Console.WriteLine($"[Pool] Active keys: {mapping}");
} }
private static string GetNextAutoColor()
{
if (_nextAutoColorIndex < DefaultAutoColors.Length)
return DefaultAutoColors[_nextAutoColorIndex++];
return "White";
}
private static ConsoleColor ParseColor(string colorName)
{
if (string.IsNullOrEmpty(colorName))
return ConsoleColor.White;
if (Enum.TryParse<ConsoleColor>(colorName, out var color))
return color;
Console.WriteLine($"[Pool] Warning: Invalid color '{colorName}', using White");
return ConsoleColor.White;
}
private static void PersistAutoAssignedColors(List<(string sectionName, string color)> assignments)
{
try
{
var json = File.ReadAllText(_configFilePath);
using var doc = JsonDocument.Parse(json);
var root = doc.RootElement;
using var stream = new MemoryStream();
using var writer = new Utf8JsonWriter(stream, new JsonWriterOptions { Indented = true });
writer.WriteStartObject();
foreach (var prop in root.EnumerateObject())
{
writer.WritePropertyName(prop.Name);
if (prop.Value.ValueKind == JsonValueKind.Object)
{
writer.WriteStartObject();
foreach (var subProp in prop.Value.EnumerateObject())
{
writer.WritePropertyName(subProp.Name);
subProp.Value.WriteTo(writer);
}
var match = assignments.FirstOrDefault(a => a.sectionName == prop.Name);
if (!string.IsNullOrEmpty(match.sectionName))
{
writer.WriteString("Color", match.color);
}
writer.WriteEndObject();
}
else
{
prop.Value.WriteTo(writer);
}
}
writer.WriteEndObject();
writer.Flush();
File.WriteAllText(_configFilePath, Encoding.UTF8.GetString(stream.ToArray()));
Console.WriteLine($"[Pool] Auto-assigned colors persisted to config: {string.Join(", ", assignments.Select(a => $"{a.sectionName}={a.color}"))}");
}
catch (Exception ex)
{
Console.WriteLine($"[Pool] Warning: Could not persist auto-assigned colors to config: {ex.Message}");
}
}
private static void EnsureStateTableExists() private static void EnsureStateTableExists()
{ {
using var conn = new System.Data.SQLite.SQLiteConnection("Data Source=" + _dbPath); using var conn = new System.Data.SQLite.SQLiteConnection("Data Source=" + _dbPath);
@@ -1685,11 +1799,17 @@ namespace URLNotesGrabberCORE
if (retryTs > now) if (retryTs > now)
{ {
var remaining = retryTs - now; var remaining = retryTs - now;
var prevColor = Console.ForegroundColor;
Console.ForegroundColor = key.ParsedColor;
Console.WriteLine($"[Pool] Key#{key.KeyNumber} ({key.SectionName}) rate-limited, clears in {remaining}s"); Console.WriteLine($"[Pool] Key#{key.KeyNumber} ({key.SectionName}) rate-limited, clears in {remaining}s");
Console.ForegroundColor = prevColor;
} }
else if (retryTs > 0) else if (retryTs > 0)
{ {
var prevColor = Console.ForegroundColor;
Console.ForegroundColor = key.ParsedColor;
Console.WriteLine($"[Pool] Key#{key.KeyNumber} ({key.SectionName}) rate-limit cleared on startup"); Console.WriteLine($"[Pool] Key#{key.KeyNumber} ({key.SectionName}) rate-limit cleared on startup");
Console.ForegroundColor = prevColor;
} }
} }
} }
@@ -1697,22 +1817,26 @@ namespace URLNotesGrabberCORE
public static ApiKeyConfig GetCurrentKey() public static ApiKeyConfig GetCurrentKey()
{ {
ApiKeyConfig key;
if (!_usePool || _overrideKey != null) if (!_usePool || _overrideKey != null)
return _overrideKey!; key = _overrideKey!;
else if (_keys.Count == 1)
if (_keys.Count == 1) key = _keys[0];
return _keys[0]; else
{
int attempts = 0; int attempts = 0;
while (attempts < _keys.Count) while (attempts < _keys.Count)
{ {
var key = _keys[_currentIndex % _keys.Count]; key = _keys[_currentIndex % _keys.Count];
_currentIndex = (_currentIndex + 1) % _keys.Count; _currentIndex = (_currentIndex + 1) % _keys.Count;
if (IsKeyAvailable(key)) if (IsKeyAvailable(key))
{ {
SaveState(); SaveState();
_activeKey = key;
Console.ForegroundColor = key.ParsedColor;
return key; return key;
} }
@@ -1726,10 +1850,14 @@ namespace URLNotesGrabberCORE
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)"); 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; key = earliest.Key;
_currentIndex = (_keys.IndexOf(chosen) + 1) % _keys.Count; _currentIndex = (_keys.IndexOf(key) + 1) % _keys.Count;
SaveState(); SaveState();
return chosen; }
_activeKey = key;
Console.ForegroundColor = key.ParsedColor;
return key;
} }
private static bool IsKeyAvailable(ApiKeyConfig key) private static bool IsKeyAvailable(ApiKeyConfig key)
@@ -1760,7 +1888,10 @@ namespace URLNotesGrabberCORE
cmd.Parameters.AddWithValue("@until", retryUntil); cmd.Parameters.AddWithValue("@until", retryUntil);
cmd.ExecuteNonQuery(); cmd.ExecuteNonQuery();
var prevColor = Console.ForegroundColor;
Console.ForegroundColor = key.ParsedColor;
Console.WriteLine($"[Pool] Key#{key.KeyNumber} ({key.SectionName}) rate-limited, retry in {retryInSeconds}s (until {DateTimeOffset.FromUnixTimeSeconds(retryUntil).LocalDateTime:HH:mm:ss})"); Console.WriteLine($"[Pool] Key#{key.KeyNumber} ({key.SectionName}) rate-limited, retry in {retryInSeconds}s (until {DateTimeOffset.FromUnixTimeSeconds(retryUntil).LocalDateTime:HH:mm:ss})");
Console.ForegroundColor = prevColor;
} }
public static void MarkAvailable(ApiKeyConfig key) public static void MarkAvailable(ApiKeyConfig key)
@@ -1771,6 +1902,11 @@ namespace URLNotesGrabberCORE
"INSERT OR REPLACE INTO ApiKeyPoolState (KeyName, RetryUntil) VALUES (@name, 0)", conn); "INSERT OR REPLACE INTO ApiKeyPoolState (KeyName, RetryUntil) VALUES (@name, 0)", conn);
cmd.Parameters.AddWithValue("@name", key.SectionName); cmd.Parameters.AddWithValue("@name", key.SectionName);
cmd.ExecuteNonQuery(); cmd.ExecuteNonQuery();
var prevColor = Console.ForegroundColor;
Console.ForegroundColor = key.ParsedColor;
Console.WriteLine($"[Pool] Key#{key.KeyNumber} ({key.SectionName}) rate-limit cleared");
Console.ForegroundColor = prevColor;
} }
public static bool IsAllRateLimited(out int minRetrySeconds) public static bool IsAllRateLimited(out int minRetrySeconds)
+1 -1
View File
@@ -84,7 +84,7 @@ namespace URLNotesGrabberCORE
if (string.IsNullOrWhiteSpace(dbPath)) if (string.IsNullOrWhiteSpace(dbPath))
dbPath = Path.Combine(Directory.GetCurrentDirectory(), "..", "..", "..", "tl.db"); dbPath = Path.Combine(Directory.GetCurrentDirectory(), "..", "..", "..", "tl.db");
ApiKeyPool.Initialize(config, dbPath, apiExplicitlySet ? apiSectionName : null); ApiKeyPool.Initialize(config, dbPath, "appsettings.json", apiExplicitlySet ? apiSectionName : null);
// Setup Dual Logging // Setup Dual Logging
bool enableFileLogging = settings.GetValue("EnableFileLogging", true); bool enableFileLogging = settings.GetValue("EnableFileLogging", true);