Author SHA1 Message Date
jimandClaude Opus 5 eded5271ea docs: revise TL.db notes for Rolodex's use of Blogs.IsActive
Replaces the Blogs.IsDeleted section. Rolodex adds no column of its own;
it reuses the crawler's existing IsActive flag, so removing a blog in the
UI also stops it being collected.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-07-29 09:56:18 -05:00
jimandClaude Opus 5 a14debd5ed docs: track TL.db schema notes in the repo
TL.db.md documents the live schema: the three content tables and their
row counts, the '.' placeholder convention the crawler writes instead of
NULL, the two incompatible date formats in Blogs.DateAdded, and the
access paths that matter on the 1.19M-row Notes table.

It also covers Blogs.IsDeleted, which Rolodex adds by ALTER TABLE and
this crawler must not write.

The file was sitting untracked next to the database it describes.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-07-29 09:54:20 -05:00
jimandClaude Opus 5 d6637266b7 fix: exclude inactive blogs from blog selection queries
Blogs.IsActive was honored only by GetBlogs and GetBlogsAll, so a blog
with IsActive = 0 was still selected for likes crawling and for output
mode. Add the filter to every remaining query that selects blog records:

- GetBlogsForLikes, all three variants (specific blog, ignoreCooldown,
  cooldown) - this is the selector that spends API quota
- GetAllBlogsWithTTFolderPath

Writes are deliberately untouched. The UPDATE statements are keyed on a
blog the caller already selected; filtering them would let the crawler
fetch a blog, pay the API cost, then fail to persist its cursor and
re-fetch the same pages on every run. Exclusion belongs at selection.

LegacyPostsDbImporter is also untouched: it reads a foreign legacy
schema that may not have the column.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-07-29 09:51:24 -05:00
jimandClaude Opus 4.8 2a02811003 Merge branch 'claude/rate-limit-behavior-474ecf'
Strip only a trailing numeric suffix from blog folder names, fixing
zomb-eh_10 importing as blog 'zomb-eh0'.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-22 16:40:16 -05:00
jimandClaude Opus 4.8 a73b597381 fix: strip only trailing numeric suffix from blog folder names
NormalizeBlogFolderName removed "_1".."_9" as unanchored substrings, so a
folder suffixed past a single digit lost the wrong characters: "_10" hit
the "_1" rule and left the trailing "0" welded to the name, importing
zomb-eh_10 as blog "zomb-eh0". That name does not exist on Tumblr, so
every post imported under it 404s on --collect forever.

Anchor the strip to a trailing _<digits> instead. This also fixes blogs
whose real name contains "_1" (some_1blog no longer becomes someblog) and
folders suffixed "_0", which were not stripped at all.

Verified against the live folder tree: zomb-eh_10 is the only existing
folder whose normalized name changes.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-22 12:58:35 -05:00
jim f9e1d2100b Merge remote master into local master 2026-07-22 12:12:33 -05:00
jimandClaude Opus 4.8 3e2b287737 Merge branch 'claude/rate-limit-behavior-474ecf'
Retry transient CDN failures instead of failing the post; throttle
--collect and --likes to 60/min.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-22 12:10:54 -05:00
jimandClaude Opus 4.8 003a504d5e fix: retry transient CDN failures instead of failing the post
A non-JSON response body (CDN 403/5xx HTML, empty body, transport error)
never reached the Tumblr API, so it says nothing about the post being
fetched. These were recorded as FAILURE, which consumed the post's single
attempt for the pass and cleared the API key's rate-limit flag on the way
through.

Classify them as Root.transientFailure and retry in place (1s/4s/10s)
before skipping. Skipped posts stay unmarked in the DB so a later launch
retries them. Ten consecutive transient failures now aborts the pass
rather than skipping post-by-post against an edge refusing all traffic.

Also:
- MarkAvailable() only on a response that reached the API, and it is now
  a no-op when the key was not flagged (was writing to the DB and logging
  on every single call)
- Only a real 429 counts as a rate limit; stop inferring one from
  X-RateLimit-* headers, which Tumblr sends on every response
- Limiters pace with AcquireAsync instead of AttemptAcquire, which did
  not wait and aborted the run once a window was saturated
- Throttle --collect and --likes from 300/min to 60/min
- Log one line per transient failure instead of the HTML body and stack
  trace; keep full detail only for a 2xx that fails to parse
- --collect returns exit 3 when a pass ends incomplete

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-22 12:10:49 -05:00
jim 16147b273e docs: document default-mode .txt ingest field parsing in AGENTS.md
Note TraverseDirectory's recognized field prefixes, multi-line
Body/Downloaded files continuation, and how RootURL now gets
populated from both the .txt Reblog root url line and the API-based
--likes flow.
2026-07-16 10:43:16 -05:00
jim 5361bb78b8 Merge remote master into txt-validation branch 2026-07-16 10:42:28 -05:00
jim 21a5525094 Capture multi-line Body/Downloaded files in default .txt ingest mode
TraverseDirectory only ever read the single line immediately after
"Body:"/"Downloaded files:", silently dropping every continuation
line (multi-paragraph HTML bodies, multiple downloaded filenames).
Switch to an indexed line scan so those two fields collect lines
until the next recognized field prefix, matching how IngestMode.cs
already handles multi-line values.
2026-07-16 10:23:16 -05:00
jim 33839930e8 Parse Reblog root url in default .txt ingest mode
TraverseDirectory (the no-args ingest path) never read the "Reblog
root url:" line, so RootURL stayed unset even though AddPost/UpdatePost
already support it via the API-based --likes flow. New scraper output
now includes this field; wire it through both AddPost call sites.
2026-07-16 10:20:50 -05:00
5 changed files with 551 additions and 202 deletions
+20
View File
@@ -11,6 +11,7 @@
- `ResponseNotes.cs`: Tumblr API response models - `ResponseNotes.cs`: Tumblr API response models
- Round-robin API key rotation with rate-limit tracking - Round-robin API key rotation with rate-limit tracking
- Automatic console color assignment per API key for output differentiation - Automatic console color assignment per API key for output differentiation
- No-argument mode (`Program.TraverseDirectory`) ingests `.txt` blog export files into `Posts` via `DataAccess.AddPost`. Recognized field prefixes live in `TraverseDirectoryFieldPrefixes`; `Body:` and `Downloaded files:` collect every following line up to the next recognized prefix (multi-line values). `RootURL` is populated from a `Reblog root url:` line the same way it's populated from the API-based `--likes` flow — both paths converge on `DataAccess.AddPost`'s `rootURL` parameter, which `UpdatePost` only overwrites when the incoming value is non-empty (existing `RootURL` is preserved otherwise)
## Developer Guidelines ## Developer Guidelines
@@ -27,6 +28,25 @@
- Preserve console color state: use save/restore pattern for temporary color changes - Preserve console color state: use save/restore pattern for temporary color changes
- API rate limits must use `ApiKeyPool.MarkRateLimited()`/`MarkAvailable()` - API rate limits must use `ApiKeyPool.MarkRateLimited()`/`MarkAvailable()`
### API Failure Classification
Tumblr sits behind a CDN that returns HTML error pages (403, 5xx) which never reach the API. These
say nothing about the item being fetched, so they must not be recorded as per-item failures.
- A response body that will not parse as JSON did not come from the API. Flag it with
`Root.transientFailure`, never as `FAILURE`
- Transient failures retry in place (`TransientBackoffSeconds`) before the item is skipped; a skipped
item stays unmarked in the DB so a later launch retries it
- `MaxConsecutiveTransient` consecutive transient failures aborts the pass rather than skipping
item-by-item against an edge that is refusing all traffic
- Only call `ApiKeyPool.MarkAvailable()` on a response that actually reached the API. A transport or
CDN failure says nothing about the key's standing and must not clear its flag
- Only a real HTTP 429 (or `meta.status == 429`) counts as a rate limit. Do not infer one from the
presence of `X-RateLimit-*` headers, which Tumblr sends on every response
- Rate limiters must pace with `await AcquireAsync()`. `AttemptAcquire()` does not wait, so a
saturated window aborts the run instead of throttling it
- Long-running commands return exit 3 when a pass ends incomplete (rate-limit pause, breaker trip, or
skipped items), so a caller can distinguish that from a clean run
### Testing ### Testing
- No existing test suite; use xUnit if adding tests - No existing test suite; use xUnit if adding tests
- Test critical logic: `ApiKeyPool` init, color parsing, config persistence - Test critical logic: `ApiKeyPool` init, color parsing, config persistence
+52 -115
View File
@@ -37,6 +37,7 @@ namespace URLNotesGrabberCORE
public string reblogKey; public string reblogKey;
public string reblogName; public string reblogName;
public string reblogURL; public string reblogURL;
public string rootURL;
public string slug; public string slug;
public string summary; public string summary;
public string tags; public string tags;
@@ -60,6 +61,7 @@ namespace URLNotesGrabberCORE
reblogKey = "."; reblogKey = ".";
reblogName = "."; reblogName = ".";
reblogURL = "."; reblogURL = ".";
rootURL = ".";
slug = "."; slug = ".";
summary = "."; summary = ".";
tags = "."; tags = ".";
@@ -1038,7 +1040,8 @@ namespace URLNotesGrabberCORE
COALESCE(LikesCursor, 0), COALESCE(LikesCursor, 0),
COALESCE(LikesNewestTimestamp, 0) COALESCE(LikesNewestTimestamp, 0)
FROM Blogs FROM Blogs
WHERE BlogName = @blog"; WHERE BlogName = @blog
AND IsActive = 1";
} }
else if (ignoreCooldown) else if (ignoreCooldown)
{ {
@@ -1051,6 +1054,7 @@ namespace URLNotesGrabberCORE
INNER JOIN Notes N ON N.NoteBlogName = B.BlogName INNER JOIN Notes N ON N.NoteBlogName = B.BlogName
WHERE N.TimeStamp >= 1535778000 WHERE N.TimeStamp >= 1535778000
AND N.rootBlogName = B.BlogName AND N.rootBlogName = B.BlogName
AND B.IsActive = 1
GROUP BY B.BlogName GROUP BY B.BlogName
ORDER BY MIN(N.Timestamp);"; ORDER BY MIN(N.Timestamp);";
} }
@@ -1065,6 +1069,7 @@ namespace URLNotesGrabberCORE
INNER JOIN Notes N ON N.NoteBlogName = B.BlogName INNER JOIN Notes N ON N.NoteBlogName = B.BlogName
WHERE N.TimeStamp >= 1535778000 WHERE N.TimeStamp >= 1535778000
AND N.rootBlogName = B.BlogName AND N.rootBlogName = B.BlogName
AND B.IsActive = 1
AND ( AND (
B.LikesPulled = 0 B.LikesPulled = 0
OR COALESCE(B.LikesLastRefreshed, 0) OR COALESCE(B.LikesLastRefreshed, 0)
@@ -2209,7 +2214,7 @@ namespace URLNotesGrabberCORE
using var connection = new SQLiteConnection("Data Source=" + DBPath); using var connection = new SQLiteConnection("Data Source=" + DBPath);
connection.Open(); connection.Open();
using var cmd = new SQLiteCommand("SELECT BlogName, TTFolderPath FROM Blogs", connection); using var cmd = new SQLiteCommand("SELECT BlogName, TTFolderPath FROM Blogs WHERE IsActive = 1", connection);
using var reader = cmd.ExecuteReader(); using var reader = cmd.ExecuteReader();
while (reader.Read()) while (reader.Read())
{ {
@@ -2609,6 +2614,10 @@ namespace URLNotesGrabberCORE
public static void MarkAvailable(ApiKeyConfig key) public static void MarkAvailable(ApiKeyConfig key)
{ {
// Called after every successful call; skip the write and the log line when nothing was flagged.
if (GetRetryUntil(key) == 0)
return;
using var conn = new System.Data.SQLite.SQLiteConnection("Data Source=" + _dbPath); using var conn = new System.Data.SQLite.SQLiteConnection("Data Source=" + _dbPath);
conn.Open(); conn.Open();
using var cmd = new System.Data.SQLite.SQLiteCommand( using var cmd = new System.Data.SQLite.SQLiteCommand(
@@ -2702,6 +2711,15 @@ namespace URLNotesGrabberCORE
private static string FormatKeyLabel(ApiKeyConfig key) => $"[Key#{key.KeyNumber}]"; private static string FormatKeyLabel(ApiKeyConfig key) => $"[Key#{key.KeyNumber}]";
private static string SummarizeBody(string body)
{
if (string.IsNullOrWhiteSpace(body))
return "(empty)";
var flat = System.Text.RegularExpressions.Regex.Replace(body, @"<[^>]+>|\s+", " ").Trim();
return flat.Length <= 80 ? flat : flat.Substring(0, 80) + "...";
}
private static int GetRetryDelaySecondsFromHeaders(IEnumerable<HeaderParameter>? headers) private static int GetRetryDelaySecondsFromHeaders(IEnumerable<HeaderParameter>? headers)
{ {
if (headers == null) if (headers == null)
@@ -2780,11 +2798,27 @@ namespace URLNotesGrabberCORE
Console.WriteLine($"{FormatKeyLabel(key)} {timestamp}\t{DateTime.Now}\t{DataAccess.UpdateAPICount()}"); Console.WriteLine($"{FormatKeyLabel(key)} {timestamp}\t{DateTime.Now}\t{DataAccess.UpdateAPICount()}");
var myDeserializedClass = new Root(); var myDeserializedClass = new Root();
// Never reached the API: there is no body to interpret, so the post's state is still unknown.
if (response.ResponseStatus != ResponseStatus.Completed)
{
myDeserializedClass.statusCode = response.ResponseStatus.ToString();
myDeserializedClass.transientFailure = true;
Console.WriteLine($"[Transient] {FormatKeyLabel(key)} transport {response.ResponseStatus}: {response.ErrorException?.Message}");
return myDeserializedClass;
}
try try
{ {
var deserializedResult = JsonConvert.DeserializeObject<Root>(myJsonResponse); var deserializedResult = JsonConvert.DeserializeObject<Root>(myJsonResponse);
if (deserializedResult != null) if (deserializedResult == null)
{ {
// Empty body behind an HTTP status: an edge/proxy response, not the API.
myDeserializedClass.statusCode = response.StatusCode.ToString();
myDeserializedClass.transientFailure = true;
Console.WriteLine($"[Transient] {FormatKeyLabel(key)} HTTP {(int)response.StatusCode} {response.StatusDescription} — empty body");
return myDeserializedClass;
}
myDeserializedClass = deserializedResult; myDeserializedClass = deserializedResult;
myDeserializedClass.rawJson = myJsonResponse; myDeserializedClass.rawJson = myJsonResponse;
@@ -2796,128 +2830,31 @@ namespace URLNotesGrabberCORE
bool metaIndicatesRateLimit = myDeserializedClass.meta != null && myDeserializedClass.meta.status == 429; 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; 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 (metaIndicatesRateLimit || metaMsgIndicatesRateLimit || response.StatusDescription?.IndexOf("Too Many", StringComparison.OrdinalIgnoreCase) >= 0 || response.StatusCode == System.Net.HttpStatusCode.TooManyRequests)
{ {
if (response?.Headers != null) myDeserializedClass.retryInSeconds = Math.Max(myDeserializedClass.retryInSeconds, GetRetryDelaySecondsFromHeaders(response.Headers));
{
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"; myDeserializedClass.statusCode = "TooManyRequests";
} }
} }
}
catch (Exception ex) catch (Exception ex)
{ {
Console.WriteLine($"Failed JSON: {myJsonResponse}"); // A body that will not parse came from infrastructure (CDN/proxy/WAF), not the Tumblr
Console.WriteLine(ex.ToString()); // API, so it says nothing about this post. Retryable, not a failure of the post itself.
myDeserializedClass.statusCode = response.StatusCode.ToString();
if (!response.IsSuccessful) if (response.StatusCode == System.Net.HttpStatusCode.TooManyRequests)
{
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.retryInSeconds = GetRetryDelaySecondsFromHeaders(response.Headers);
myDeserializedClass.statusCode = "TooManyRequests"; myDeserializedClass.statusCode = "TooManyRequests";
} }
} else
} {
myDeserializedClass.transientFailure = true;
Console.WriteLine($"[Transient] {FormatKeyLabel(key)} HTTP {(int)response.StatusCode} {response.StatusDescription} — unparseable body: {SummarizeBody(myJsonResponse)}");
// A 2xx that will not parse is a genuine surprise; keep the detail for that case only.
if (response.IsSuccessful)
Console.WriteLine(ex.ToString());
} }
} }
+155 -59
View File
@@ -281,7 +281,7 @@ namespace URLNotesGrabberCORE
managedCollectRun = true; managedCollectRun = true;
} }
CollectNotes(settings.GetValue<string>("PathOutput"), withoutNotesOnly, beforeDate, managedCollectRun).GetAwaiter().GetResult(); exitCode = CollectNotes(settings.GetValue<string>("PathOutput"), withoutNotesOnly, beforeDate, managedCollectRun).GetAwaiter().GetResult();
break; break;
case "--blogsR": //collect notes from all posts case "--blogsR": //collect notes from all posts
@@ -452,7 +452,7 @@ namespace URLNotesGrabberCORE
Console.WriteLine("--importposts [path-to-posts.db]\t One-time migration: copy legacy ThreeTxtFileHelper posts.db rows into TL.db"); Console.WriteLine("--importposts [path-to-posts.db]\t One-time migration: copy legacy ThreeTxtFileHelper posts.db rows into TL.db");
Console.WriteLine(); Console.WriteLine();
Console.WriteLine("Exit status: 0 = success; 1 = unexpected error; 2 = usage error (unknown command or bad/missing arguments)"); Console.WriteLine("Exit status: 0 = success; 1 = unexpected error; 2 = usage error (unknown command or bad/missing arguments); 3 = incomplete (--collect paused on a rate limit, or skipped posts after transient API failures) - relaunch to resume");
} }
static void WritePostBlogsToFile(string outPath) static void WritePostBlogsToFile(string outPath)
@@ -582,16 +582,11 @@ namespace URLNotesGrabberCORE
protected static string NormalizeBlogFolderName(string folderName) protected static string NormalizeBlogFolderName(string folderName)
{ {
return folderName // Archive tools suffix duplicate blog folders with _1, _2, ... _10 and beyond. Strip only a
.Replace("_1", "") // trailing numeric suffix: unanchored substring removal ate the "_1" inside "_10" and left the
.Replace("_2", "") // "0" welded to the name (zomb-eh_10 -> zomb-eh0), and mangled any blog whose real name
.Replace("_3", "") // contains "_1". A blog name is never a prefix of itself plus "_<digits>", so this is safe.
.Replace("_4", "") return System.Text.RegularExpressions.Regex.Replace(folderName, @"_\d+$", "");
.Replace("_5", "")
.Replace("_6", "")
.Replace("_7", "")
.Replace("_8", "")
.Replace("_9", "");
} }
static async Task FetchAndStoreReplyText(string blogName, long postID, long timestamp) static async Task FetchAndStoreReplyText(string blogName, long postID, long timestamp)
@@ -847,7 +842,9 @@ namespace URLNotesGrabberCORE
RateLimiter limiter = new SlidingWindowRateLimiter(new SlidingWindowRateLimiterOptions RateLimiter limiter = new SlidingWindowRateLimiter(new SlidingWindowRateLimiterOptions
{ {
PermitLimit = 300, // 1/sec average, matching --collect: the CDN reacts to aggregate traffic from the IP,
// not to per-command rates.
PermitLimit = 60,
QueueProcessingOrder = QueueProcessingOrder.OldestFirst, QueueProcessingOrder = QueueProcessingOrder.OldestFirst,
QueueLimit = 1, QueueLimit = 1,
Window = TimeSpan.FromMinutes(1), Window = TimeSpan.FromMinutes(1),
@@ -883,7 +880,9 @@ namespace URLNotesGrabberCORE
while (hasMoreLikes) while (hasMoreLikes)
{ {
using RateLimitLease lease = limiter.AttemptAcquire(1); // Wait for a permit rather than giving up on one: the limiter paces the loop, it is
// not a failure condition. Only one acquire is ever pending, so QueueLimit = 1 suffices.
using RateLimitLease lease = await limiter.AcquireAsync(1);
if (!lease.IsAcquired) if (!lease.IsAcquired)
{ {
Console.WriteLine("!@@@@@@@ - Rate Limited Exceeded: No Lease Available"); Console.WriteLine("!@@@@@@@ - Rate Limited Exceeded: No Lease Available");
@@ -1129,6 +1128,43 @@ if (shouldInsert)
} }
} }
// Backoff between in-place retries of a transient infrastructure failure. Most CDN 403s and edge
// 5xxs clear within a few seconds, so retrying here saves the post its single attempt for the pass.
static readonly int[] TransientBackoffSeconds = { 1, 4, 10 };
// Fetches one page, retrying transient failures in place. Rate limits are returned to the caller
// untouched — those are handled by pausing the whole run, not by retrying this post.
static async Task<Root> FetchNotesPage(Tuple<string, long, long, long> post, string beforeTimestamp)
{
Root response = null!;
for (int attempt = 0; ; attempt++)
{
var key = ApiKeyPool.GetCurrentKey();
response = await APIAccess.GrabNotes(key, post.Item1, post.Item2, beforeTimestamp);
if (response.statusCode == "TooManyRequests")
{
ApiKeyPool.MarkRateLimited(key, response.retryInSeconds > 0 ? response.retryInSeconds : 60);
return response;
}
if (!response.transientFailure)
{
// Only a response that actually reached the API says anything about the key's standing.
ApiKeyPool.MarkAvailable(key);
return response;
}
if (attempt >= TransientBackoffSeconds.Length)
return response;
int delay = TransientBackoffSeconds[attempt];
Console.WriteLine($"[Transient] retry {attempt + 1}/{TransientBackoffSeconds.Length} in {delay}s");
await Task.Delay(delay * 1000);
}
}
static async Task<string> GrabNotes(Tuple<string, long, long, long> post) static async Task<string> GrabNotes(Tuple<string, long, long, long> post)
{ {
try try
@@ -1146,18 +1182,16 @@ if (shouldInsert)
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 key = ApiKeyPool.GetCurrentKey(); var response = await FetchNotesPage(post, beforeTimestamp);
var response = await APIAccess.GrabNotes(key, post.Item1, post.Item2, beforeTimestamp);
if (response.statusCode == "TooManyRequests") if (response.statusCode == "TooManyRequests")
{
int retry = response.retryInSeconds > 0 ? response.retryInSeconds : 60;
ApiKeyPool.MarkRateLimited(key, retry);
return "TooManyRequests"; return "TooManyRequests";
}
if (response.meta?.status != 429) if (response.transientFailure)
ApiKeyPool.MarkAvailable(key); {
Console.WriteLine($"[Skip] {post.Item1}/{post.Item2} — {response.statusCode} after {TransientBackoffSeconds.Length} retries");
return "Transient";
}
if (IsNotFound(response)) if (IsNotFound(response))
{ {
@@ -1166,19 +1200,6 @@ if (shouldInsert)
Thread.Sleep(1000); Thread.Sleep(1000);
return "NotFound"; return "NotFound";
} }
if (response == null)
{
Console.WriteLine("##### Response is null - API Failure? ###");
return "FAILURE";
}
if (response.statusCode == "TooManyRequests")
{
int retry = response.retryInSeconds > 0 ? response.retryInSeconds : 60;
ApiKeyPool.MarkRateLimited(key, retry);
ApiKeyPool.SleepUntilAnyAvailable(30);
return response.statusCode;
}
// Pagination loop // Pagination loop
while (true) while (true)
@@ -1228,16 +1249,15 @@ if (shouldInsert)
break; break;
} }
key = ApiKeyPool.GetCurrentKey(); response = await FetchNotesPage(post, beforeTimestamp);
response = await APIAccess.GrabNotes(key, post.Item1, post.Item2, beforeTimestamp);
if (response.statusCode == "TooManyRequests") if (response.statusCode == "TooManyRequests")
{
int retry = response.retryInSeconds > 0 ? response.retryInSeconds : 60;
ApiKeyPool.MarkRateLimited(key, retry);
return "TooManyRequests"; return "TooManyRequests";
if (response.transientFailure)
{
Console.WriteLine($"[Skip] {post.Item1}/{post.Item2} — {response.statusCode} on page {page} after {TransientBackoffSeconds.Length} retries");
return "Transient";
} }
if (response.meta?.status != 429)
ApiKeyPool.MarkAvailable(key);
if (IsNotFound(response)) if (IsNotFound(response))
{ {
@@ -1268,7 +1288,11 @@ if (shouldInsert)
return "UNKNOWN"; return "UNKNOWN";
} }
static async Task CollectNotes(string outPath, bool withoutNotesOnly = true, DateTime? beforeDate = null, bool managedRun = false) // Consecutive transient failures that mean the API edge is rejecting traffic wholesale rather than
// blipping on one post. Past this, skipping post-by-post would just hammer a closed door.
const int MaxConsecutiveTransient = 10;
static async Task<int> CollectNotes(string outPath, bool withoutNotesOnly = true, DateTime? beforeDate = null, bool managedRun = false)
{ {
List<Tuple<string, long, long, long>> posts = DataAccess.GetPosts(withoutNotesOnly, beforeDate); List<Tuple<string, long, long, long>> posts = DataAccess.GetPosts(withoutNotesOnly, beforeDate);
@@ -1277,9 +1301,14 @@ if (shouldInsert)
// post that keeps returning FAILURE/UNKNOWN. Successful/NotFound posts drop out via the DB filter anyway. // post that keeps returning FAILURE/UNKNOWN. Successful/NotFound posts drop out via the DB filter anyway.
HashSet<(string, long)> attempted = new HashSet<(string, long)>(); HashSet<(string, long)> attempted = new HashSet<(string, long)>();
int skipped = 0;
int consecutiveTransient = 0;
RateLimiter limiter = new SlidingWindowRateLimiter(new SlidingWindowRateLimiterOptions RateLimiter limiter = new SlidingWindowRateLimiter(new SlidingWindowRateLimiterOptions
{ {
PermitLimit = 300, // 1/sec average. Sustained higher rates draw CDN-level 403s that the API's own rate-limit
// headers never warn about, so this sits well under the per-key quota on purpose.
PermitLimit = 60,
QueueProcessingOrder = QueueProcessingOrder.OldestFirst, QueueProcessingOrder = QueueProcessingOrder.OldestFirst,
QueueLimit = 1, QueueLimit = 1,
Window = TimeSpan.FromMinutes(1), Window = TimeSpan.FromMinutes(1),
@@ -1304,19 +1333,35 @@ if (shouldInsert)
ApiKeyPool.SleepUntilAnyAvailable(30); ApiKeyPool.SleepUntilAnyAvailable(30);
string status; // Wait for a permit rather than giving up on one: the limiter paces the loop, it is
// not a failure condition. Only one acquire is ever pending, so QueueLimit = 1 suffices.
using RateLimitLease lease = limiter.AttemptAcquire(1); using RateLimitLease lease = await limiter.AcquireAsync(1);
if (lease.IsAcquired) if (!lease.IsAcquired)
{ {
Console.WriteLine("!@@@@@@@ - Rate Limited Exceeded: No Lease Available");
return 3; // abort without completing the run so a later launch resumes
}
Console.WriteLine("{0,32} - {1,15} - {2}", post.Item1, post.Item2, DateTimeOffset.FromUnixTimeSeconds(post.Item4).ToString()); Console.WriteLine("{0,32} - {1,15} - {2}", post.Item1, post.Item2, DateTimeOffset.FromUnixTimeSeconds(post.Item4).ToString());
status = await GrabNotes(post); string status = await GrabNotes(post);
if (status == "Transient")
{
// Retries in GrabNotes are already exhausted. Skip the post so the pass can make
// progress; it stays unmarked in the DB, so the next launch picks it up again.
attempted.Add((post.Item1, post.Item2));
skipped++;
consecutiveTransient++;
if (consecutiveTransient >= MaxConsecutiveTransient)
{
Console.WriteLine($"[Abort] {consecutiveTransient} consecutive transient failures - the API edge is rejecting traffic. Pausing run; relaunch to resume. ({skipped} post(s) skipped)");
return 3;
}
} }
else else
{ {
Console.WriteLine("!@@@@@@@ - Rate Limited Exceeded: No Lease Available"); consecutiveTransient = 0;
return; // throttle: abort without completing the run so a later launch resumes
}
if (status == "Success") if (status == "Success")
{ {
@@ -1333,8 +1378,8 @@ if (shouldInsert)
{ {
// Throttle, not a real per-post failure: don't consume this post's single attempt. // Throttle, not a real per-post failure: don't consume this post's single attempt.
// Abort the pass without completing so a later launch resumes against the same cutoff. // Abort the pass without completing so a later launch resumes against the same cutoff.
Console.WriteLine("GrabNotes Result: TooManyRequests - pausing run; relaunch to resume."); Console.WriteLine($"GrabNotes Result: TooManyRequests - pausing run; relaunch to resume. ({skipped} post(s) skipped)");
return; return 3;
} }
else else
{ {
@@ -1342,6 +1387,7 @@ if (shouldInsert)
attempted.Add((post.Item1, post.Item2)); attempted.Add((post.Item1, post.Item2));
Console.WriteLine("GrabNotes Result: " + status); Console.WriteLine("GrabNotes Result: " + status);
} }
}
// Re-fetch the updated list after processing the current post // Re-fetch the updated list after processing the current post
posts = DataAccess.GetPosts(withoutNotesOnly, beforeDate); posts = DataAccess.GetPosts(withoutNotesOnly, beforeDate);
@@ -1354,14 +1400,42 @@ if (shouldInsert)
DataAccess.CompleteCollectRun(); DataAccess.CompleteCollectRun();
Console.WriteLine("Full re-check run complete."); Console.WriteLine("Full re-check run complete.");
} }
if (skipped > 0)
{
Console.WriteLine($"Pass finished with {skipped} post(s) skipped after transient failures; relaunch to retry them.");
return 3;
}
return 0;
} }
catch (Exception ex) catch (Exception ex)
{ {
Console.WriteLine(ex.ToString()); Console.WriteLine(ex.ToString());
return 1;
} }
} }
// Field prefixes TraverseDirectory recognizes as the start of a new record field.
// Used to know where a multi-line Body/Downloaded files value ends.
private static readonly string[] TraverseDirectoryFieldPrefixes = new[]
{
"Post id:", "Reblog url:", "Reblog name:", "Reblog root url:", "Downloaded files:",
"Reblog key:", "Date:", "Body:", "Post url:", "Answer:", "Audio Caption:", "Blog Name:",
"Link:", "Photo Caption:", "Photo url:", "Question:", "Quote:", "Slug:", "Summary:",
"Tags:", "Title:"
};
private static bool IsTraverseDirectoryFieldLine(string line)
{
foreach (var prefix in TraverseDirectoryFieldPrefixes)
{
if (line.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) return true;
}
return false;
}
static void TraverseDirectory(string path, string outPath, List<string> contains, ref int postsAdded, string blogName = "", string startFromBlogName = "", bool logRecordImports = false) static void TraverseDirectory(string path, string outPath, List<string> contains, ref int postsAdded, string blogName = "", string startFromBlogName = "", bool logRecordImports = false)
{ {
DateTime directoryStart = DateTime.Now; DateTime directoryStart = DateTime.Now;
@@ -1398,8 +1472,10 @@ if (shouldInsert)
var urls = new List<string>(); var urls = new List<string>();
var reblog = new ReblogRecord(); var reblog = new ReblogRecord();
foreach (string line in File.ReadLines(file)) string[] fileLines = File.ReadAllLines(file);
for (int lineIndex = 0; lineIndex < fileLines.Length; lineIndex++)
{ {
string line = fileLines[lineIndex];
if (line.StartsWith("Post id:", StringComparison.OrdinalIgnoreCase)) if (line.StartsWith("Post id:", StringComparison.OrdinalIgnoreCase))
{ {
if (reblog.reblogName != "." && reblog.postID != "." && reblog.date != "." && reblog.reblogURL != "." && reblog.downloadedFiles == ".") if (reblog.reblogName != "." && reblog.postID != "." && reblog.date != "." && reblog.reblogURL != "." && reblog.downloadedFiles == ".")
@@ -1420,7 +1496,7 @@ if (shouldInsert)
DataAccess.AddPost(curDir, long.Parse(reblog.postID), reblog.reblogURL, reblog.date, reblog.postURL, reblog.slug, reblog.reblogKey, DataAccess.AddPost(curDir, long.Parse(reblog.postID), reblog.reblogURL, reblog.date, reblog.postURL, reblog.slug, reblog.reblogKey,
reblog.reblogName, reblog.summary, reblog.quote, reblog.body, reblog.tags, reblog.link, reblog.photoURL, reblog.reblogName, reblog.summary, reblog.quote, reblog.body, reblog.tags, reblog.link, reblog.photoURL,
reblog.photoCaption, reblog.downloadedFiles, reblog.audioCaption, reblog.question, reblog.answer, reblog.photoCaption, reblog.downloadedFiles, reblog.audioCaption, reblog.question, reblog.answer,
reblog.title, false); reblog.title, false, rootURL: reblog.rootURL);
recordImportStopwatch.Stop(); recordImportStopwatch.Stop();
postsAdded++; postsAdded++;
@@ -1445,9 +1521,21 @@ if (shouldInsert)
{ {
reblog.reblogName = line.Substring(13).Trim(); reblog.reblogName = line.Substring(13).Trim();
} }
if (line.StartsWith(@"Reblog root url:", StringComparison.OrdinalIgnoreCase))
{
reblog.rootURL = line.Substring(16).Trim();
}
if (line.StartsWith(@"Downloaded files:", StringComparison.OrdinalIgnoreCase)) if (line.StartsWith(@"Downloaded files:", StringComparison.OrdinalIgnoreCase))
{ {
reblog.downloadedFiles = line.Substring(17).Trim(); var valueLines = new List<string> { line.Substring(17).Trim() };
int nextLineIndex = lineIndex + 1;
while (nextLineIndex < fileLines.Length && !IsTraverseDirectoryFieldLine(fileLines[nextLineIndex]))
{
valueLines.Add(fileLines[nextLineIndex]);
nextLineIndex++;
}
reblog.downloadedFiles = string.Join("\n", valueLines).Trim();
lineIndex = nextLineIndex - 1;
} }
if (line.StartsWith(@"Reblog key:", StringComparison.OrdinalIgnoreCase)) if (line.StartsWith(@"Reblog key:", StringComparison.OrdinalIgnoreCase))
{ {
@@ -1459,7 +1547,15 @@ if (shouldInsert)
} }
if (line.StartsWith(@"Body:", StringComparison.OrdinalIgnoreCase)) if (line.StartsWith(@"Body:", StringComparison.OrdinalIgnoreCase))
{ {
reblog.body = line.Substring(6).Trim(); var valueLines = new List<string> { line.Substring(6).Trim() };
int nextLineIndex = lineIndex + 1;
while (nextLineIndex < fileLines.Length && !IsTraverseDirectoryFieldLine(fileLines[nextLineIndex]))
{
valueLines.Add(fileLines[nextLineIndex]);
nextLineIndex++;
}
reblog.body = string.Join("\n", valueLines).Trim();
lineIndex = nextLineIndex - 1;
} }
if (line.StartsWith(@"Post url:", StringComparison.OrdinalIgnoreCase)) if (line.StartsWith(@"Post url:", StringComparison.OrdinalIgnoreCase))
{ {
@@ -1548,7 +1644,7 @@ if (shouldInsert)
DataAccess.AddPost(curDir, long.Parse(reblog.postID), reblog.reblogURL, reblog.date, reblog.postURL, reblog.slug, reblog.reblogKey, DataAccess.AddPost(curDir, long.Parse(reblog.postID), reblog.reblogURL, reblog.date, reblog.postURL, reblog.slug, reblog.reblogKey,
reblog.reblogName, reblog.summary, reblog.quote, reblog.body, reblog.tags, reblog.link, reblog.photoURL, reblog.reblogName, reblog.summary, reblog.quote, reblog.body, reblog.tags, reblog.link, reblog.photoURL,
reblog.photoCaption, reblog.downloadedFiles, reblog.audioCaption, reblog.question, reblog.answer, reblog.photoCaption, reblog.downloadedFiles, reblog.audioCaption, reblog.question, reblog.answer,
reblog.title, true); reblog.title, true, rootURL: reblog.rootURL);
recordImportStopwatch.Stop(); recordImportStopwatch.Stop();
postsAdded++; postsAdded++;
+4
View File
@@ -85,6 +85,10 @@ namespace URLNotesGrabberCORE
public int retryInSeconds { get; set; } public int retryInSeconds { get; set; }
public string rawJson { get; set; } public string rawJson { get; set; }
// The request never reached the Tumblr API (transport error, or an edge/CDN response with a
// non-JSON body). Says nothing about the post, so the caller should retry rather than fail it.
public bool transientFailure { get; set; }
} }
// Classes for Posts API endpoint (for reply_text) // Classes for Posts API endpoint (for reply_text)
+292
View File
@@ -0,0 +1,292 @@
# `TL.db` — schema notes
The SQLite database behind **URLNotesGrabberCORE** and its sibling crawlers, and the one
[Rolodex](https://git.basso.land/jim/Rolodex) reads.
Everything below was read out of the live file, not inferred from code. Counts are as of
**2026-07-29**; re-run the queries at the bottom to refresh them.
- Journal mode: **WAL**`TL.db-wal` and `TL.db-shm` live beside the file and are part of
the database. Copying `TL.db` alone gives you whatever was last checkpointed, not the
current state.
- Page size: 4096.
---
## The three content tables
| Table | Rows | What it is |
|---|--:|---|
| `Blogs` | 144,367 | The crawl registry — one row per known blog, plus crawl-state flags |
| `Posts` | 14,589 | Stored post content. Only 3,602 blogs actually have any |
| `Notes` | 1,189,604 | The engagement graph: `NoteBlogName` acted on `(RootBlogName, PostID)` |
The engagement graph is the interesting part. 31,888 distinct blogs appear as engagers —
far more than the 3,602 that have stored posts — which is what makes this a social graph
rather than a post archive.
### `Blogs`
```sql
CREATE TABLE "Blogs" (
"BlogName" TEXT,
"HasBeenOutput" INTEGER DEFAULT 0,
"IsActive" INTEGER DEFAULT 1,
"DateAdded" TEXT NOT NULL DEFAULT '12/24/25',
"ByLikes" INTEGER NOT NULL DEFAULT 0,
"LikesPulled" INTEGER NOT NULL DEFAULT 0,
"LikesCursor" INTEGER DEFAULT 0,
"DateModified" TEXT,
"DateCreated" TEXT,
LikesNewestTimestamp INTEGER DEFAULT 0,
LikesLastRefreshed INTEGER DEFAULT 0,
LikesLastNewCount INTEGER DEFAULT 0,
TTFolderPath TEXT,
PRIMARY KEY("BlogName")
);
```
`BlogName` is the primary key, so it is the only indexed way in. There is no index on any
flag or date — filtering or sorting on those scans all 144k rows, which is affordable
here and is not on `Notes`.
Flag distribution: `IsActive = 1` on 144,366 of 144,367 rows, `HasBeenOutput = 1` on
5,369, `ByLikes = 1` on 2. `IsActive` carries a second meaning as of Rolodex — see
[`Blogs.IsActive`](#blogsisactive--now-written-by-two-applications) below.
The columns after `DateCreated` were added later by `ALTER TABLE`, which is why they carry
no quoting in the stored DDL. That is the normal way this schema grows.
**`DateAdded` is not written consistently.** 126,423 rows hold ISO `yyyy-MM-dd HH:mm:ss`;
17,944 hold US-format `M/d/yy` from a bulk import. As text those two sort into different
parts of the table, so anything ordering or range-filtering on this column has to
normalise first — see `DateSql` in Rolodex.
### `Posts`
```sql
CREATE TABLE "Posts" (
"BlogName" TEXT,
"PostID" INTEGER,
"HasNotesGathered" INTEGER DEFAULT 0,
"reblogURL" TEXT,
"NotFound" INTEGER DEFAULT 0,
"PostDate" TEXT,
"NotesGatheredDateTime" INTEGER NOT NULL DEFAULT 1729746000,
"HasImage" INTEGER NOT NULL DEFAULT 0,
"PostURL" TEXT,
"Slug" TEXT,
"ReblogKey" TEXT,
"ReblogName" TEXT,
"Summary" TEXT,
"Quote" TEXT,
"Body" TEXT,
"Tags" TEXT,
"Link" TEXT,
"PhotoURL" TEXT,
"PhotoCaption" TEXT,
"DownloadedFiles" TEXT,
"AudioCaption" TEXT,
"Question" TEXT,
"Answer" TEXT,
"Title" TEXT,
"ByLikes" INTEGER NOT NULL DEFAULT 0,
"RootBlogName" TEXT,
"RootURL" TEXT,
"DateModified" TEXT,
"DateCreated" TEXT,
PostType TEXT,
PRIMARY KEY("BlogName","PostID")
);
```
**The key is `(BlogName, PostID)`, not `PostID`.** This matters more than it looks: 325
post IDs exist under more than one blog, so an ID on its own is both ambiguous *and*
unindexed. Any lookup should carry the blog name, and a batch lookup should group by blog
so it stays on the leading column of the key.
Notable:
- **`PostType` is `NULL` on all 14,589 rows.** The column exists but nothing has ever
populated it. Treat it as unpopulated rather than as a type discriminator.
- `HasImage = 1` on 14,268 rows — nearly all of them. It records that the post *had* a
picture, not that a usable URL was kept, so it is not a reliable predictor that anything
will render.
- `PhotoURL` is largely unused; in practice the image markup lives inside `Body`.
- `NotFound = 1` on 4,663 rows — posts that have since been deleted upstream.
- The content columns (`Body`, `Quote`, `Question`, `Answer`, …) are the heavy ones. List
views should not select them.
### `Notes`
```sql
CREATE TABLE "Notes" (
"RootBlogName" TEXT,
"PostID" INTEGER,
"NoteBlogName" TEXT,
"TimeStamp" INTEGER,
"Type" TEXT,
"replyText" TEXT DEFAULT '.',
"DatetimeCrawled" TEXT DEFAULT '2/12/26 12am',
"DateModified" TEXT,
"DateCreated" TEXT,
PRIMARY KEY("RootBlogName","PostID","TimeStamp","Type","NoteBlogName")
);
CREATE INDEX "Notes_idx_06e01ae3" ON "Notes" ("TimeStamp" DESC);
CREATE INDEX "ix_NoteBlogName01" ON "Notes" ("NoteBlogName");
```
One row per engagement event. `TimeStamp` is **unix seconds** — unlike every date column
elsewhere in the schema, which are text.
| `Type` | Rows | Share |
|---|--:|--:|
| `like` | 947,955 | 79.7% |
| `reblog` | 224,323 | 18.9% |
| `reply` | 15,201 | 1.3% |
| `posted` | 2,106 | 0.2% |
| `post_attribution` | 19 | — |
At 1.19M rows this is the table that dictates how the whole database has to be queried:
- **Nothing should run an unbounded `SELECT` or a bare `COUNT(*)` here.** A count scans
the lot on every call.
- The only fast access paths are the primary key's leading columns (`RootBlogName`, then
`PostID`) and `ix_NoteBlogName01` on `NoteBlogName`. "Notes received by a blog" and
"notes given by a blog" are both cheap; almost nothing else is.
- Ordering by anything but `TimeStamp` is a full sort of whatever the filters leave.
- `replyText` is `'.'` on 1,174,706 rows — only `reply` notes carry real text.
### Referential integrity
There are no foreign keys, and the tables do not perfectly agree:
- 4 `Posts` rows name a blog with no `Blogs` row.
- 15 of the 31,888 distinct engagers have no `Blogs` row.
So a name appearing in `Notes` or `Posts` is not a guarantee that the registry knows about
it. Joins from those tables back to `Blogs` should tolerate a miss.
---
## The `'.'` placeholder convention
**The crawler writes a single dot into text columns it has no value for, rather than
`NULL`.** This is the single most surprising thing about the schema and it affects every
consumer.
| Column | `'.'` rows |
|---|--:|
| `Notes.replyText` | 1,174,706 |
| `Posts.Title` | 13,144 |
| `Posts.Body` | 172 |
Any query whose output reaches a human should collapse it:
```sql
NULLIF(NULLIF(SomeColumn, '.'), '') AS SomeColumn
```
Empty string turns up too, hence the double `NULLIF`. Not every column is affected —
`Blogs.TTFolderPath` and `Blogs.DateModified` currently have zero dot rows — but new
columns tend to acquire them, so treat cleaning as the default for any text column
rendered to a user.
---
## Supporting tables
Crawler bookkeeping. Rolodex ignores all of these.
| Table | Rows | What it is |
|---|--:|---|
| `DailyAPICount` | 133 | `(Date TEXT PK, APICount INTEGER)` — per-day API call tally against the rate limit |
| `ApiKeyPoolState` | 2 | `(KeyName TEXT PK, RetryUntil INTEGER)` — per-key backoff; `RetryUntil` is unix seconds |
| `ApiKeyPoolMeta` | 1 | `(Id PK CHECK (Id = 1), LastIndex)` — round-robin cursor. Singleton by check constraint |
| `CollectRunState` | 1 | `(Id PK CHECK (Id = 1), RunCutoff, RunComplete, RunStarted, RunCompletedAt)` — resume state for an interrupted collection run. Also a singleton |
---
## `Blogs.IsActive` — now written by two applications
`IsActive` has always been the crawler's work-selection flag. `GetBlogs` in
`DataAccess.cs` joins on it to decide what to collect:
```sql
SELECT NoteBlogName, count(*) FROM notes
INNER JOIN blogs ON blogs.BlogName = notes.NoteBlogName
WHERE blogs.IsActive = @isActive AND ...
```
Nothing inside the crawler *writes* it — it is an input, set from outside.
**Rolodex is now one of the things that sets it.** Removing a blog through the Rolodex UI
runs exactly this:
```sql
UPDATE Blogs SET IsActive = 0 WHERE BlogName = ?;
```
Rolodex adds no column and changes no schema. It reuses this flag because the two meanings
were judged to be one decision: a blog you do not want in the browsing UI is a blog you do
not want to keep crawling. Removal therefore stops collection, and the Rolodex
confirmation screen says so before anyone commits.
- `1` (or absent/NULL) — live. Crawled, and visible in Rolodex.
- `0` — removed. Not crawled, hidden from the Rolodex registry, dashboard counts and
engagement rollups.
Restoring is the same `UPDATE` with a `1`. Nothing is destroyed either way: the blog's
`Posts` and `Notes` rows are never touched, and Rolodex deliberately keeps showing them
under its Posts and Notes pages. Removing a blog hides the blog, not what it collected.
### What other tools need to know
1. **Setting `IsActive = 0` now also hides the blog from Rolodex**, and setting it back to
`1` makes it reappear. If another tool deactivates blogs in bulk, it is also removing
them from the browsing UI — which may be exactly right, but it is no longer a
crawler-only decision.
2. **Re-crawling a removed blog will not bring it back**, since nothing in the crawler
writes the flag. An `INSERT OR REPLACE` on the `Blogs` row *would*, by resetting it to
the column default of `1`. Prefer an `UPDATE` of the specific columns, or
`INSERT … ON CONFLICT DO UPDATE SET` naming only the columns being refreshed.
3. **NULL is treated as live.** The column is `INTEGER DEFAULT 1` with no `NOT NULL`, so
Rolodex reads it through `COALESCE(IsActive, 1)`. A NULL therefore leaves the blog
visible rather than stranding it outside both the registry and the removed list, where
no screen could reach it. Write `0` or `1`, not NULL.
4. **Backing the feature out is a configuration change, not a migration.** Because there is
no Rolodex-owned column, setting `Rolodex__EnableBlogDeletion=false` is the whole of it;
there is nothing to drop. Any blogs already at `IsActive = 0` simply go back to being
ordinary inactive blogs.
---
## Reproducing the numbers
```sql
SELECT 'Blogs', COUNT(*) FROM Blogs
UNION ALL SELECT 'Posts', COUNT(*) FROM Posts
UNION ALL SELECT 'Notes', COUNT(*) FROM Notes;
-- note type mix
SELECT Type, COUNT(*) FROM Notes GROUP BY Type ORDER BY 2 DESC;
-- the two date shapes in Blogs.DateAdded
SELECT CASE WHEN DateAdded LIKE '____-__-__%' THEN 'ISO' ELSE 'US' END, COUNT(*)
FROM Blogs GROUP BY 1;
-- post IDs that are ambiguous without a blog name
SELECT COUNT(*) FROM (
SELECT PostID FROM Posts GROUP BY PostID HAVING COUNT(DISTINCT BlogName) > 1);
-- rows that reference a blog the registry does not have
SELECT COUNT(*) FROM Posts p
WHERE NOT EXISTS (SELECT 1 FROM Blogs b WHERE b.BlogName = p.BlogName);
```
Open the file read-only so an inspection can never disturb a running crawl:
```bash
sqlite3 "file:TL.db?mode=ro" ".schema"
```