Compare commits
26
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8f4177a0c9 | ||
|
|
721224bc13 | ||
|
|
ef6629d86a | ||
|
|
6320e2c0c9 | ||
|
|
6136901cc7 | ||
|
|
83e35a2323 | ||
|
|
f0ccac6503 | ||
|
|
e1d2eb48c2 | ||
|
|
3c85a05afc | ||
|
|
60912c882d | ||
|
|
8a4ab2402d | ||
|
|
05ec465f74 | ||
|
|
eded5271ea | ||
|
|
a14debd5ed | ||
|
|
d6637266b7 | ||
|
|
2a02811003 | ||
|
|
a73b597381 | ||
|
|
f9e1d2100b | ||
|
|
3e2b287737 | ||
|
|
003a504d5e | ||
|
|
16147b273e | ||
|
|
5361bb78b8 | ||
|
|
21a5525094 | ||
|
|
33839930e8 | ||
|
|
f541ec4260 | ||
|
|
f549f020e1 |
@@ -11,6 +11,7 @@
|
||||
- `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
|
||||
- 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
|
||||
|
||||
@@ -27,6 +28,132 @@
|
||||
- Preserve console color state: use save/restore pattern for temporary color changes
|
||||
- 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
|
||||
|
||||
### `IsActive` Is Not Ours To Write
|
||||
`Blogs.IsActive`, `Posts.IsActive` and `Notes.IsActive` are removal flags set by other tools
|
||||
(Rolodex). `0` means removed; anything else, including `NULL`, means live. Full detail in
|
||||
`URLNotesGrabberCORE/TL.db.md`.
|
||||
|
||||
- **Never write any `IsActive` column.** Not in an `INSERT` column list, not in an `UPDATE`,
|
||||
and never via `INSERT OR REPLACE` on these tables — that resets the column default and
|
||||
un-removes the row. Re-crawling a removed row must refresh its content and leave the flag
|
||||
where it was
|
||||
- **Filter at selection, not at write.** Every query that *selects* posts, notes or blogs
|
||||
excludes removed rows. Update statements stay keyed on a row the caller already selected;
|
||||
filtering them would spend API quota and then fail to persist the result
|
||||
- `Posts.IsActive` and `Notes.IsActive` are **optional** — they are absent from databases
|
||||
that predate them, and naming a missing column is a hard SQLite error. Compose the filter
|
||||
with `AndIsActive`/`WhereIsActive` in `DataAccess.cs`, which return
|
||||
`COALESCE(IsActive, 1) = 1` only when `HasIsActiveColumn` finds the column. `Blogs.IsActive`
|
||||
is not optional and is filtered directly
|
||||
- Do not add these columns from this app, and do not add them to the missing-column list in
|
||||
`verify-db-schema.sql`
|
||||
|
||||
### `DateModified` Tracks Real Changes Only
|
||||
`Blogs.DateModified`, `Posts.DateModified` and `Notes.DateModified` must move only when a
|
||||
column beside `DateModified` itself actually changed. Re-crawling or re-ingesting identical
|
||||
content has to leave the row — and its timestamp — untouched, or downstream consumers cannot
|
||||
tell a refreshed row from a rewritten one.
|
||||
|
||||
- Enforce it in the `WHERE` clause, not in C#. Every `UPDATE` that sets `DateModified` ends
|
||||
with an `AND (<col> <> @param OR ...)` term covering every column in its `SET` list, so
|
||||
SQLite matches zero rows on a no-op and never writes
|
||||
- Compare NULL-safely: `IFNULL(col, '') <> IFNULL(@param, '')` for text,
|
||||
`IFNULL(col, 0) <> @param` for integer flags. A bare `col <> @param` is NULL on a NULL
|
||||
column and silently skips the row that most needs writing
|
||||
- Where NULL is not equivalent to the default, spell it out. The `HasBeenOutput = 0` stamps
|
||||
use `(HasBeenOutput IS NULL OR HasBeenOutput <> 0)` because the selection queries test
|
||||
`HasBeenOutput = 0`, which a NULL would never match
|
||||
- Dynamic `SET` lists (`UpdatePostContentFields`) build the guard alongside the assignments
|
||||
so the two lists cannot drift apart
|
||||
- These statements now return 0 rows for "found but unchanged" as well as "not found".
|
||||
Callers that read `ExecuteNonQuery()` must not treat 0 as "row missing"
|
||||
|
||||
**`Posts.NotesGatheredDateTime` is crawl bookkeeping, not content.** It moves on every
|
||||
`-collect` pass and says nothing about the post, so it must never move `DateModified` on its
|
||||
own. `UpdatePostMarkNotesCollected` still writes it every pass but wraps the timestamp in
|
||||
`DateModified = CASE WHEN IFNULL(HasNotesGathered, 0) <> 1 THEN @dateModified ELSE
|
||||
DateModified END` — SQLite evaluates `SET` expressions against the pre-`UPDATE` row, so only
|
||||
the flag flipping counts as a modification. Use this shape for any column that has to be
|
||||
refreshed unconditionally without being a change. `Blogs.LikesLastRefreshed` is the
|
||||
deliberate exception: a refresh pass is treated as a real event on the blog row.
|
||||
|
||||
**`Blogs.DateAdded` is write-once.** `AddBlog`'s `INSERT` is the only place that sets it. A
|
||||
new post arriving for a known blog reopens `HasBeenOutput` but must leave `DateAdded` alone —
|
||||
a new post is not a new blog, and rewriting the column both destroys the registration date
|
||||
and makes every insert look like a change.
|
||||
|
||||
**`"."` in a `Posts` content field means "not supplied", not "empty".** `ReblogRecord`
|
||||
(`TraverseDirectory`'s parser for the local `.txt` export tree) and the `--likes` API path
|
||||
both default every content field to the literal string `"."` when their source has no value
|
||||
for it, then pass that straight to `UpdatePost`. A blog with two export folders in different
|
||||
field formats (a duplicate `_2` folder, or a Tumblr export whose field set changed over time)
|
||||
sends one record with a real `Title`/`Tags`/`Slug` and another with those fields `"."`
|
||||
because that format never had a line for them — and without a guard, re-importing both on
|
||||
every run flips the row back and forth forever, bumping `DateModified` on every pass even
|
||||
though the true content never changes.
|
||||
|
||||
- Every content column in `UpdatePost`'s `SET` list is guarded the same way `RootBlogName`/
|
||||
`RootURL` already were: `col = CASE WHEN @col = '.' THEN col ELSE @col END`. A `"."`
|
||||
parameter leaves the existing value alone instead of overwriting it
|
||||
- The change-detection `WHERE` clause carries the same exception —
|
||||
`(@col <> '.' AND IFNULL(col, '') <> @col) OR ...` — so a `"."`-only difference does not
|
||||
make the statement fire at all, and `DateModified` stays put
|
||||
- Deliberately narrow: only the literal `"."` is the sentinel. An explicit empty string from
|
||||
a real record still overwrites, same as before this fix. `postID`, `BlogName`, `hasImage`,
|
||||
`ByLikes` are not part of this convention and are unaffected
|
||||
- If a new content field is added to `Posts`/`UpdatePost`, decide explicitly whether its
|
||||
source can legitimately supply `"."` as "field absent" before deciding whether it needs
|
||||
the same `CASE` treatment — don't assume every column needs it
|
||||
|
||||
**`--ingest` (`UpsertPostFromTextFile`) uses `NULL`, not `"."`, for the same "field absent"
|
||||
convention, and reconciling exactly this kind of duplicate IS the feature's job.**
|
||||
`IngestMode` strips a trailing `_N` from the folder name before it ever reaches
|
||||
`UpsertPostFromTextFile`, so a duplicate export folder collapses onto the same `BlogName` on
|
||||
purpose — the whole point is to merge multiple differently-formatted files for the same post
|
||||
into one row. `IngestMode.G(key)` returns `null` (not `"."`) when a field's line is absent
|
||||
from a given file, `LegacyPostsDbImporter` passes `null` straight from a `NULL` source column,
|
||||
and files are walked in raw filesystem enumeration order — never sorted — so which file's call
|
||||
lands last for a given `(BlogName, PostID)` is arbitrary.
|
||||
|
||||
- Before the fix, the `UPDATE` branch set every column unconditionally, so whichever file
|
||||
processed last for a `PostID` would null out every field its own record didn't carry —
|
||||
silently erasing real `Title`/`Slug`/`Tags`/… another file had, the opposite of what
|
||||
`--ingest` exists to do. This is worse than the `"."` case above: that one only caused
|
||||
churn (the two writes canceled out); this one loses data, and which posts lose which
|
||||
fields depends on filesystem enumeration order
|
||||
- Same shape of fix, `NULL` instead of `"."` as the sentinel: `col = CASE WHEN @col IS NULL
|
||||
THEN col ELSE @col END` in the `SET` list, `(@col IS NOT NULL AND IFNULL(col, '') <> @col)
|
||||
OR ...` in the change-detection
|
||||
- Same narrow rule: only `NULL` (the field's line was never present in this file) is the
|
||||
sentinel. `G()` already distinguishes this from "present but blank" — a dictionary miss is
|
||||
`null`, an empty value after the prefix is `""` — so an explicitly blank field still
|
||||
overwrites
|
||||
- `HasImage` is **not** guarded and remains a known gap: `IngestMode` always computes a
|
||||
concrete `bool` (defaulting `false` when a file has no `Has Image:` line), so there is no
|
||||
way for this function to tell "this format says no image" from "this format doesn't report
|
||||
it at all" without changing the parameter to `bool?` and threading that through
|
||||
`IngestMode`/`LegacyPostsDbImporter`. Fix this the same way if `--ingest` is observed
|
||||
downgrading a post's `HasImage` from `1` to `0`
|
||||
|
||||
### Testing
|
||||
- No existing test suite; use xUnit if adding tests
|
||||
- Test critical logic: `ApiKeyPool` init, color parsing, config persistence
|
||||
|
||||
+425
-363
File diff suppressed because it is too large
Load Diff
@@ -29,6 +29,8 @@ namespace URLNotesGrabberCORE
|
||||
Console.WriteLine($"Reading legacy posts.db: {legacyDbPath}");
|
||||
|
||||
int blogsCopied = 0;
|
||||
int blogPathsWritten = 0;
|
||||
int blogsWithoutPath = 0;
|
||||
int postsUpserted = 0;
|
||||
int errors = 0;
|
||||
|
||||
@@ -48,7 +50,13 @@ namespace URLNotesGrabberCORE
|
||||
if (string.IsNullOrWhiteSpace(blogName)) continue;
|
||||
try
|
||||
{
|
||||
DataAccess.SetBlogTTFolderPath(blogName, ttFolderPath);
|
||||
// A legacy row whose TTFolderPath was already NULL copies nothing.
|
||||
// Counting it as "copied" is what hid the fact that this import has
|
||||
// never populated a single path.
|
||||
if (string.IsNullOrWhiteSpace(ttFolderPath))
|
||||
blogsWithoutPath++;
|
||||
else if (DataAccess.SetBlogTTFolderPath(blogName, ttFolderPath.Trim()))
|
||||
blogPathsWritten++;
|
||||
blogsCopied++;
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -58,7 +66,7 @@ namespace URLNotesGrabberCORE
|
||||
}
|
||||
}
|
||||
}
|
||||
Console.WriteLine($" Blogs copied: {blogsCopied}");
|
||||
Console.WriteLine($" Blogs seen: {blogsCopied}, TTFolderPath written: {blogPathsWritten}, legacy rows with no path: {blogsWithoutPath}");
|
||||
|
||||
// 2) Copy Posts
|
||||
try
|
||||
@@ -138,7 +146,8 @@ namespace URLNotesGrabberCORE
|
||||
}
|
||||
|
||||
Console.WriteLine($"\n========== Legacy import summary ==========");
|
||||
Console.WriteLine($"Blogs copied: {blogsCopied}");
|
||||
Console.WriteLine($"Blogs seen: {blogsCopied}");
|
||||
Console.WriteLine($"Paths written: {blogPathsWritten} (legacy rows with no path: {blogsWithoutPath})");
|
||||
Console.WriteLine($"Posts upserted: {postsUpserted}");
|
||||
Console.WriteLine($"Errors: {errors}");
|
||||
return errors == 0 ? 0 : 2;
|
||||
|
||||
@@ -8,28 +8,51 @@ namespace URLNotesGrabberCORE
|
||||
// field order). Reads from TL.db via DataAccess.GetAllPostsForBlog.
|
||||
public static class OutputMode
|
||||
{
|
||||
public static int Run(IConfiguration config)
|
||||
public static int Run(IConfiguration config, string[]? args = null)
|
||||
{
|
||||
DataAccess.EnsureTTFileHelperColumnsExist();
|
||||
|
||||
var blogs = DataAccess.GetAllBlogsWithTTFolderPath();
|
||||
Console.WriteLine($"Found {blogs.Count} blog(s) to process.");
|
||||
string dbPath = DataAccess.GetActiveDbPath();
|
||||
Console.WriteLine($"Database: {Path.GetFullPath(dbPath)}");
|
||||
|
||||
foreach (var (blogName, ttFolderPath) in blogs)
|
||||
if (!RefreshPaths(config, args ?? Array.Empty<string>()))
|
||||
return 1;
|
||||
|
||||
var blogs = DataAccess.GetAllBlogsWithTTFolderPath();
|
||||
int activeBlogs = DataAccess.CountActiveBlogs();
|
||||
Console.WriteLine($"{blogs.Count} of {activeBlogs} active blog(s) have a TTFolderPath.");
|
||||
|
||||
if (blogs.Count == 0)
|
||||
{
|
||||
Console.WriteLine($"\nNothing to export: no blog in {Path.GetFullPath(dbPath)} has a TTFolderPath.");
|
||||
Console.WriteLine("Point --output at a TumblThree root so it can populate them: --output <root>,");
|
||||
Console.WriteLine("or set appSettings:PathTTRoot so the refresh runs automatically.");
|
||||
return 1;
|
||||
}
|
||||
|
||||
int missingFolderCount = 0;
|
||||
int writtenCount = 0;
|
||||
|
||||
foreach (var (blogName, folder) in blogs)
|
||||
{
|
||||
Console.WriteLine($"\nProcessing blog: {blogName}");
|
||||
|
||||
if (string.IsNullOrWhiteSpace(ttFolderPath) || !Directory.Exists(ttFolderPath))
|
||||
// A stored path that this machine cannot see means the value was written on
|
||||
// another machine -- re-running --updatepaths locally is the fix, so say so
|
||||
// rather than lumping it in with "not set".
|
||||
if (!Directory.Exists(folder))
|
||||
{
|
||||
Console.WriteLine($" TTFolderPath does not exist or is not set. Skipping.");
|
||||
Console.WriteLine($" TTFolderPath folder not found: {folder}. Skipping.");
|
||||
missingFolderCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
Console.WriteLine($" TTFolderPath: {ttFolderPath}");
|
||||
Console.WriteLine($" TTFolderPath: {folder}");
|
||||
writtenCount++;
|
||||
|
||||
try
|
||||
{
|
||||
foreach (var bakFile in Directory.GetFiles(ttFolderPath, "*.bak"))
|
||||
foreach (var bakFile in Directory.GetFiles(folder, "*.bak"))
|
||||
File.Delete(bakFile);
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -37,7 +60,7 @@ namespace URLNotesGrabberCORE
|
||||
Console.WriteLine($" Error deleting .bak files: {ex.Message}");
|
||||
}
|
||||
|
||||
RenameExistingTxtFilesToBak(ttFolderPath);
|
||||
RenameExistingTxtFilesToBak(folder);
|
||||
|
||||
var posts = DataAccess.GetAllPostsForBlog(blogName);
|
||||
Console.WriteLine($" Found {posts.Count} post(s) for this blog.");
|
||||
@@ -46,7 +69,7 @@ namespace URLNotesGrabberCORE
|
||||
foreach (var typeGroup in grouped)
|
||||
{
|
||||
string postType = typeGroup.Key ?? "Unknown";
|
||||
string outputFilePath = Path.Combine(ttFolderPath, $"{postType}.txt");
|
||||
string outputFilePath = Path.Combine(folder, $"{postType}.txt");
|
||||
var ordered = typeGroup.OrderBy(p => p.Date).ToList();
|
||||
Console.WriteLine($" Writing {ordered.Count} post(s) to {postType}.txt");
|
||||
|
||||
@@ -65,10 +88,57 @@ namespace URLNotesGrabberCORE
|
||||
}
|
||||
}
|
||||
|
||||
Console.WriteLine("\nOutput mode complete.");
|
||||
Console.WriteLine($"\nOutput mode complete. {writtenCount} blog(s) exported, {missingFolderCount} skipped for a missing folder.");
|
||||
|
||||
if (writtenCount == 0)
|
||||
Console.WriteLine("Every TTFolderPath points at a folder this machine cannot see. The paths were most likely written on another machine -- re-run --updatepaths <root> here so they match local drive letters.");
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Re-reads the TumblThree Index metadata into Blogs.TTFolderPath before exporting.
|
||||
// A TL.db synced between machines cannot hold one absolute path that is valid on
|
||||
// both, so the stored paths are only trustworthy on the machine that wrote them --
|
||||
// which makes this refresh part of a normal export rather than a separate chore.
|
||||
// Returns false only when the run should stop.
|
||||
private static bool RefreshPaths(IConfiguration config, string[] args)
|
||||
{
|
||||
var settings = config.GetSection("appSettings");
|
||||
|
||||
if (args.Any(a => string.Equals(a, "--norefresh", StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
Console.WriteLine("Path refresh skipped (--norefresh); exporting to whatever paths TL.db already holds.");
|
||||
return true;
|
||||
}
|
||||
|
||||
string? root = args.FirstOrDefault(a => !a.StartsWith("--", StringComparison.Ordinal))
|
||||
?? settings.GetValue<string>("PathTTRoot");
|
||||
|
||||
var result = UpdateBlogPathsRunner.Scan(root, verbose: false);
|
||||
|
||||
switch (result.Outcome)
|
||||
{
|
||||
case UpdateBlogPathsRunner.ScanOutcome.NoRootConfigured:
|
||||
Console.WriteLine("No TumblThree root configured (appSettings:PathTTRoot is empty and none was passed),");
|
||||
Console.WriteLine("so TTFolderPath was not refreshed. Pass one as --output <root> to refresh it.");
|
||||
return true;
|
||||
|
||||
case UpdateBlogPathsRunner.ScanOutcome.IndexFolderMissing:
|
||||
// Silently exporting stale paths here would defeat the point of folding
|
||||
// the refresh in, so a bad root is a hard stop.
|
||||
Console.WriteLine($"Index folder not found at: {result.IndexPath}");
|
||||
Console.WriteLine("Fix the root (or pass --norefresh to export the paths already in TL.db).");
|
||||
return false;
|
||||
|
||||
default:
|
||||
Console.WriteLine($"Refreshed paths from {result.IndexPath}: " +
|
||||
$"{result.MetadataFiles} metadata file(s), {result.Written} written, " +
|
||||
$"{result.Unchanged} already correct, {result.NoLocation} without a location, " +
|
||||
$"{result.NoMatchingRow} without a blog row, {result.Errors} error(s).");
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
private static void RenameExistingTxtFilesToBak(string folderPath)
|
||||
{
|
||||
try
|
||||
|
||||
+181
-81
@@ -5,7 +5,6 @@ using Microsoft.Extensions.Configuration;
|
||||
using System.Configuration;
|
||||
using System.Threading;
|
||||
using Microsoft.Extensions.Diagnostics.Latency;
|
||||
using static System.Runtime.InteropServices.JavaScript.JSType;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace URLNotesGrabberCORE
|
||||
@@ -140,7 +139,10 @@ namespace URLNotesGrabberCORE
|
||||
Console.SetOut(dualLogger);
|
||||
}
|
||||
|
||||
List<string> contains = settings.GetValue<string>("ContainsList").Split(',').ToList();
|
||||
string? containsListSetting = settings.GetValue<string>("ContainsList");
|
||||
if (string.IsNullOrEmpty(containsListSetting))
|
||||
throw new InvalidOperationException("ContainsList is not configured in appsettings.json");
|
||||
List<string> contains = containsListSetting.Split(',').ToList();
|
||||
bool logTraversalRecordImports = settings.GetValue("LogTraversalRecordImports", false);
|
||||
|
||||
if (args.Length == 0) //Traverse folder structure to add posts and thus blogs to DB
|
||||
@@ -279,7 +281,7 @@ namespace URLNotesGrabberCORE
|
||||
managedCollectRun = true;
|
||||
}
|
||||
|
||||
CollectNotes(settings.GetValue<string>("PathOutput"), withoutNotesOnly, beforeDate, managedCollectRun).GetAwaiter().GetResult();
|
||||
exitCode = CollectNotes(settings.GetValue<string>("PathOutput"), withoutNotesOnly, beforeDate, managedCollectRun).GetAwaiter().GetResult();
|
||||
break;
|
||||
|
||||
case "--blogsR": //collect notes from all posts
|
||||
@@ -341,7 +343,7 @@ namespace URLNotesGrabberCORE
|
||||
break;
|
||||
|
||||
case "--output":
|
||||
exitCode = OutputMode.Run(config);
|
||||
exitCode = OutputMode.Run(config, args.Skip(1).ToArray());
|
||||
break;
|
||||
|
||||
case "--revert":
|
||||
@@ -437,7 +439,9 @@ namespace URLNotesGrabberCORE
|
||||
|
||||
Console.WriteLine("--ingest [blogname]\t Ingest Tumblr .txt exports from appSettings:PathTTRoot into TL.db (all blogs, or single blog if name given)");
|
||||
|
||||
Console.WriteLine("--output\t Export posts from TL.db back to .txt files in each blog's TTFolderPath");
|
||||
Console.WriteLine("--output [rootPath]\t Refresh Blogs.TTFolderPath from <root>\\Index (or appSettings:PathTTRoot), then export posts from TL.db back to .txt files in each blog's folder");
|
||||
|
||||
Console.WriteLine("--output --norefresh\t Export without refreshing TTFolderPath first");
|
||||
|
||||
Console.WriteLine("--revert [blogname]\t Recursively scan the PathInput tree and restore *.bak back to *.txt (current .txt saved as next-free .bkN); optional blogname filters by path substring");
|
||||
|
||||
@@ -450,7 +454,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();
|
||||
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)
|
||||
@@ -580,16 +584,11 @@ namespace URLNotesGrabberCORE
|
||||
|
||||
protected static string NormalizeBlogFolderName(string folderName)
|
||||
{
|
||||
return folderName
|
||||
.Replace("_1", "")
|
||||
.Replace("_2", "")
|
||||
.Replace("_3", "")
|
||||
.Replace("_4", "")
|
||||
.Replace("_5", "")
|
||||
.Replace("_6", "")
|
||||
.Replace("_7", "")
|
||||
.Replace("_8", "")
|
||||
.Replace("_9", "");
|
||||
// Archive tools suffix duplicate blog folders with _1, _2, ... _10 and beyond. Strip only a
|
||||
// trailing numeric suffix: unanchored substring removal ate the "_1" inside "_10" and left the
|
||||
// "0" welded to the name (zomb-eh_10 -> zomb-eh0), and mangled any blog whose real name
|
||||
// contains "_1". A blog name is never a prefix of itself plus "_<digits>", so this is safe.
|
||||
return System.Text.RegularExpressions.Regex.Replace(folderName, @"_\d+$", "");
|
||||
}
|
||||
|
||||
static async Task FetchAndStoreReplyText(string blogName, long postID, long timestamp)
|
||||
@@ -845,7 +844,9 @@ namespace URLNotesGrabberCORE
|
||||
|
||||
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,
|
||||
QueueLimit = 1,
|
||||
Window = TimeSpan.FromMinutes(1),
|
||||
@@ -881,7 +882,9 @@ namespace URLNotesGrabberCORE
|
||||
|
||||
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)
|
||||
{
|
||||
Console.WriteLine("!@@@@@@@ - Rate Limited Exceeded: No Lease Available");
|
||||
@@ -1127,6 +1130,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)
|
||||
{
|
||||
try
|
||||
@@ -1144,18 +1184,16 @@ if (shouldInsert)
|
||||
string beforeTimestamp = post.Item3.ToString();
|
||||
bool hasReplies = false;
|
||||
const int maxPages = 500;
|
||||
var key = ApiKeyPool.GetCurrentKey();
|
||||
var response = await APIAccess.GrabNotes(key, post.Item1, post.Item2, beforeTimestamp);
|
||||
var response = await FetchNotesPage(post, 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 (response.transientFailure)
|
||||
{
|
||||
Console.WriteLine($"[Skip] {post.Item1}/{post.Item2} — {response.statusCode} after {TransientBackoffSeconds.Length} retries");
|
||||
return "Transient";
|
||||
}
|
||||
|
||||
if (IsNotFound(response))
|
||||
{
|
||||
@@ -1164,19 +1202,6 @@ if (shouldInsert)
|
||||
Thread.Sleep(1000);
|
||||
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
|
||||
while (true)
|
||||
@@ -1226,16 +1251,15 @@ if (shouldInsert)
|
||||
break;
|
||||
}
|
||||
|
||||
key = ApiKeyPool.GetCurrentKey();
|
||||
response = await APIAccess.GrabNotes(key, post.Item1, post.Item2, beforeTimestamp);
|
||||
response = await FetchNotesPage(post, beforeTimestamp);
|
||||
if (response.statusCode == "TooManyRequests")
|
||||
{
|
||||
int retry = response.retryInSeconds > 0 ? response.retryInSeconds : 60;
|
||||
ApiKeyPool.MarkRateLimited(key, retry);
|
||||
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))
|
||||
{
|
||||
@@ -1266,7 +1290,11 @@ if (shouldInsert)
|
||||
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);
|
||||
|
||||
@@ -1275,9 +1303,14 @@ if (shouldInsert)
|
||||
// post that keeps returning FAILURE/UNKNOWN. Successful/NotFound posts drop out via the DB filter anyway.
|
||||
HashSet<(string, long)> attempted = new HashSet<(string, long)>();
|
||||
|
||||
int skipped = 0;
|
||||
int consecutiveTransient = 0;
|
||||
|
||||
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,
|
||||
QueueLimit = 1,
|
||||
Window = TimeSpan.FromMinutes(1),
|
||||
@@ -1302,43 +1335,60 @@ if (shouldInsert)
|
||||
|
||||
ApiKeyPool.SleepUntilAnyAvailable(30);
|
||||
|
||||
string status;
|
||||
|
||||
using RateLimitLease lease = limiter.AttemptAcquire(1);
|
||||
if (lease.IsAcquired)
|
||||
{
|
||||
Console.WriteLine("{0,32} - {1,15} - {2}", post.Item1, post.Item2, DateTimeOffset.FromUnixTimeSeconds(post.Item4).ToString());
|
||||
status = await GrabNotes(post);
|
||||
}
|
||||
else
|
||||
// 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)
|
||||
{
|
||||
Console.WriteLine("!@@@@@@@ - Rate Limited Exceeded: No Lease Available");
|
||||
return; // throttle: abort without completing the run so a later launch resumes
|
||||
return 3; // abort without completing the run so a later launch resumes
|
||||
}
|
||||
|
||||
if (status == "Success")
|
||||
Console.WriteLine("{0,32} - {1,15} - {2}", post.Item1, post.Item2, DateTimeOffset.FromUnixTimeSeconds(post.Item4).ToString());
|
||||
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));
|
||||
DataAccess.UpdatePostMarkNotesCollected(post.Item1, post.Item2);
|
||||
}
|
||||
else if (status == "NotFound")
|
||||
{
|
||||
attempted.Add((post.Item1, post.Item2));
|
||||
Console.WriteLine("GrabNotes Result: NotFound");
|
||||
DataAccess.UpdatePostMarkNotFound(post.Item1, post.Item2);
|
||||
}
|
||||
else if (status == "TooManyRequests")
|
||||
{
|
||||
// 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.
|
||||
Console.WriteLine("GrabNotes Result: TooManyRequests - pausing run; relaunch to resume.");
|
||||
return;
|
||||
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
|
||||
{
|
||||
// FAILURE / UNKNOWN: count as attempted so the pass can finish instead of retrying forever.
|
||||
attempted.Add((post.Item1, post.Item2));
|
||||
Console.WriteLine("GrabNotes Result: " + status);
|
||||
consecutiveTransient = 0;
|
||||
|
||||
if (status == "Success")
|
||||
{
|
||||
attempted.Add((post.Item1, post.Item2));
|
||||
DataAccess.UpdatePostMarkNotesCollected(post.Item1, post.Item2);
|
||||
}
|
||||
else if (status == "NotFound")
|
||||
{
|
||||
attempted.Add((post.Item1, post.Item2));
|
||||
Console.WriteLine("GrabNotes Result: NotFound");
|
||||
DataAccess.UpdatePostMarkNotFound(post.Item1, post.Item2);
|
||||
}
|
||||
else if (status == "TooManyRequests")
|
||||
{
|
||||
// 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.
|
||||
Console.WriteLine($"GrabNotes Result: TooManyRequests - pausing run; relaunch to resume. ({skipped} post(s) skipped)");
|
||||
return 3;
|
||||
}
|
||||
else
|
||||
{
|
||||
// FAILURE / UNKNOWN: count as attempted so the pass can finish instead of retrying forever.
|
||||
attempted.Add((post.Item1, post.Item2));
|
||||
Console.WriteLine("GrabNotes Result: " + status);
|
||||
}
|
||||
}
|
||||
|
||||
// Re-fetch the updated list after processing the current post
|
||||
@@ -1352,14 +1402,42 @@ if (shouldInsert)
|
||||
DataAccess.CompleteCollectRun();
|
||||
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)
|
||||
{
|
||||
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)
|
||||
{
|
||||
DateTime directoryStart = DateTime.Now;
|
||||
@@ -1396,8 +1474,10 @@ if (shouldInsert)
|
||||
var urls = new List<string>();
|
||||
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 (reblog.reblogName != "." && reblog.postID != "." && reblog.date != "." && reblog.reblogURL != "." && reblog.downloadedFiles == ".")
|
||||
@@ -1418,7 +1498,7 @@ if (shouldInsert)
|
||||
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.photoCaption, reblog.downloadedFiles, reblog.audioCaption, reblog.question, reblog.answer,
|
||||
reblog.title, false);
|
||||
reblog.title, false, rootURL: reblog.rootURL);
|
||||
recordImportStopwatch.Stop();
|
||||
|
||||
postsAdded++;
|
||||
@@ -1443,9 +1523,21 @@ if (shouldInsert)
|
||||
{
|
||||
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))
|
||||
{
|
||||
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))
|
||||
{
|
||||
@@ -1457,7 +1549,15 @@ if (shouldInsert)
|
||||
}
|
||||
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))
|
||||
{
|
||||
@@ -1546,7 +1646,7 @@ if (shouldInsert)
|
||||
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.photoCaption, reblog.downloadedFiles, reblog.audioCaption, reblog.question, reblog.answer,
|
||||
reblog.title, true);
|
||||
reblog.title, true, rootURL: reblog.rootURL);
|
||||
recordImportStopwatch.Stop();
|
||||
|
||||
postsAdded++;
|
||||
|
||||
@@ -85,6 +85,10 @@ namespace URLNotesGrabberCORE
|
||||
public int retryInSeconds { 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)
|
||||
|
||||
@@ -0,0 +1,333 @@
|
||||
# `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.
|
||||
|
||||
---
|
||||
|
||||
## `Posts.IsActive` and `Notes.IsActive` — optional, and not in this database yet
|
||||
|
||||
The same flag is being extended to the two content tables, with the same meaning: `0` is
|
||||
removed, anything else — including `NULL` — is live. **Neither column exists in the live
|
||||
`TL.db` as of 2026-07-29**; the DDL quoted above for `Posts` and `Notes` is complete. Like
|
||||
`Blogs.IsActive`, they are written from outside this crawler.
|
||||
|
||||
The crawler therefore treats both as optional, and as nothing it owns:
|
||||
|
||||
- **It never writes them.** No `INSERT` column list names `IsActive`, no `UPDATE` sets it,
|
||||
and `MapPrefixToColumn` — the only place a column name is chosen at runtime — cannot map
|
||||
to it. Re-crawling a removed post or note refreshes its content and leaves the flag at
|
||||
`0`. There is no `INSERT OR REPLACE` on `Posts` or `Notes` for a default to be reset by.
|
||||
- **It filters on them only when they exist.** `HasIsActiveColumn` in `DataAccess.cs` asks
|
||||
`PRAGMA table_info` once per table per database path and caches the answer; the filter
|
||||
is `COALESCE(IsActive, 1) = 1`, and it is dropped entirely when the column is absent.
|
||||
Naming a missing column is a hard SQLite error, so this is what lets one build run
|
||||
against databases on both sides of the change. The cache lives for the process — adding
|
||||
the columns to a live database takes effect on the next run.
|
||||
|
||||
Every read that selects posts or notes carries the filter: `GetPosts`, `GetReplies`,
|
||||
`GetRepliesWithMissingText`, `GetRepliesWithFilledText`, `GetAllPostTextColumns`,
|
||||
`GetAllPostsForBlog`, `GetPost`, `GetPostByIdAnyBlog`, and the engagement queries that
|
||||
count or join `Notes` (`GetBlogs`, `GetBlogsAll`, `GetBlogsForLikes`). The one deliberate
|
||||
omission is the `LEFT JOIN Notes` in `GetPosts`: nothing is selected from it and it can
|
||||
neither add nor remove a row, so filtering it would buy nothing.
|
||||
|
||||
`LegacyPostsDbImporter` is unfiltered too — it reads a foreign legacy database whose
|
||||
`Posts` table is not this schema.
|
||||
|
||||
Two consequences worth stating plainly, both inherited from how `Blogs.IsActive` is
|
||||
handled:
|
||||
|
||||
1. **Removal hides a row; it does not freeze it.** The write paths are keyed on a post the
|
||||
caller already selected, so an ingest or a correction run still overwrites the content
|
||||
of a removed post. Only selection is filtered.
|
||||
2. **`NULL` is live.** Write `0` or `1`, not `NULL`, but a `NULL` leaves the row visible
|
||||
rather than stranding it.
|
||||
|
||||
---
|
||||
|
||||
## 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"
|
||||
```
|
||||
@@ -4,34 +4,64 @@ namespace URLNotesGrabberCORE
|
||||
{
|
||||
// Port of ThreeTxtFileHelper/UpdateBlogPaths.cs. Reads .tumblr / .tmblrpriv metadata
|
||||
// files from a root\Index folder and populates Blogs.TTFolderPath in TL.db.
|
||||
//
|
||||
// Scan() is the reusable engine: --updatepaths wraps it as a standalone command and
|
||||
// --output calls it as a refresh step, because a TL.db synced between machines cannot
|
||||
// hold one absolute path that is correct on both.
|
||||
public static class UpdateBlogPathsRunner
|
||||
{
|
||||
public static int Run(string rootPath)
|
||||
public enum ScanOutcome
|
||||
{
|
||||
Completed,
|
||||
NoRootConfigured,
|
||||
IndexFolderMissing
|
||||
}
|
||||
|
||||
public sealed class ScanResult
|
||||
{
|
||||
public ScanOutcome Outcome { get; init; }
|
||||
public string RootPath { get; init; } = string.Empty;
|
||||
public string IndexPath { get; init; } = string.Empty;
|
||||
public int MetadataFiles { get; init; }
|
||||
public int Written { get; init; }
|
||||
public int Unchanged { get; init; }
|
||||
public int NoLocation { get; init; }
|
||||
public int NoMatchingRow { get; init; }
|
||||
public int Errors { get; init; }
|
||||
}
|
||||
|
||||
// verbose: log a line per metadata file. --updatepaths wants that detail; --output
|
||||
// only wants the counts, since a few hundred lines before the export would bury it.
|
||||
public static ScanResult Scan(string? rootPath, bool verbose)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(rootPath))
|
||||
{
|
||||
Console.WriteLine("UpdateBlogPaths: rootPath is required.");
|
||||
return 1;
|
||||
}
|
||||
return new ScanResult { Outcome = ScanOutcome.NoRootConfigured };
|
||||
|
||||
DataAccess.EnsureTTFileHelperColumnsExist();
|
||||
|
||||
string indexPath = Path.Combine(rootPath, "Index");
|
||||
if (!Directory.Exists(indexPath))
|
||||
{
|
||||
Console.WriteLine($"Index folder not found at: {indexPath}");
|
||||
return 1;
|
||||
return new ScanResult
|
||||
{
|
||||
Outcome = ScanOutcome.IndexFolderMissing,
|
||||
RootPath = rootPath,
|
||||
IndexPath = indexPath
|
||||
};
|
||||
}
|
||||
|
||||
Console.WriteLine($"Scanning Index folder: {indexPath}");
|
||||
|
||||
var blogFiles = Directory.GetFiles(indexPath, "*.tumblr")
|
||||
.Concat(Directory.GetFiles(indexPath, "*.tmblrpriv"))
|
||||
.ToList();
|
||||
|
||||
Console.WriteLine($"Found {blogFiles.Count} blog metadata files");
|
||||
if (verbose)
|
||||
Console.WriteLine($"Found {blogFiles.Count} blog metadata files");
|
||||
|
||||
int updatedCount = 0;
|
||||
int unchangedCount = 0;
|
||||
int noLocationCount = 0;
|
||||
int noRowCount = 0;
|
||||
int errorCount = 0;
|
||||
|
||||
foreach (var blogFile in blogFiles)
|
||||
{
|
||||
@@ -44,27 +74,91 @@ namespace URLNotesGrabberCORE
|
||||
|
||||
if (root.TryGetProperty("FileDownloadLocation", out JsonElement locationElement))
|
||||
{
|
||||
string? fileDownloadLocation = locationElement.GetString();
|
||||
string? fileDownloadLocation = locationElement.GetString()?.Trim();
|
||||
if (!string.IsNullOrWhiteSpace(fileDownloadLocation))
|
||||
{
|
||||
DataAccess.SetBlogTTFolderPath(blogName, fileDownloadLocation);
|
||||
updatedCount++;
|
||||
Console.WriteLine($"Updated {blogName}: {fileDownloadLocation}");
|
||||
// Report the database's answer, not the fact that the file parsed.
|
||||
if (DataAccess.SetBlogTTFolderPath(blogName, fileDownloadLocation))
|
||||
{
|
||||
updatedCount++;
|
||||
if (verbose)
|
||||
Console.WriteLine($"Updated {blogName}: {fileDownloadLocation}");
|
||||
}
|
||||
else if (DataAccess.BlogExists(blogName))
|
||||
{
|
||||
unchangedCount++;
|
||||
}
|
||||
else
|
||||
{
|
||||
noRowCount++;
|
||||
Console.WriteLine($"No Blogs row named '{blogName}' -- path not stored (name may differ in case)");
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
noLocationCount++;
|
||||
if (verbose)
|
||||
Console.WriteLine($"Empty FileDownloadLocation in {blogFile}");
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.WriteLine($"No FileDownloadLocation found in {blogFile}");
|
||||
noLocationCount++;
|
||||
if (verbose)
|
||||
Console.WriteLine($"No FileDownloadLocation found in {blogFile}");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
errorCount++;
|
||||
Console.WriteLine($"Error processing {blogFile}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
Console.WriteLine($"\nUpdated {updatedCount} blogs with TTFolderPath");
|
||||
return 0;
|
||||
return new ScanResult
|
||||
{
|
||||
Outcome = ScanOutcome.Completed,
|
||||
RootPath = rootPath,
|
||||
IndexPath = indexPath,
|
||||
MetadataFiles = blogFiles.Count,
|
||||
Written = updatedCount,
|
||||
Unchanged = unchangedCount,
|
||||
NoLocation = noLocationCount,
|
||||
NoMatchingRow = noRowCount,
|
||||
Errors = errorCount
|
||||
};
|
||||
}
|
||||
|
||||
public static int Run(string rootPath)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(rootPath))
|
||||
{
|
||||
Console.WriteLine("UpdateBlogPaths: rootPath is required.");
|
||||
return 1;
|
||||
}
|
||||
|
||||
string indexPath = Path.Combine(rootPath, "Index");
|
||||
Console.WriteLine($"Scanning Index folder: {indexPath}");
|
||||
|
||||
var result = Scan(rootPath, verbose: true);
|
||||
|
||||
if (result.Outcome == ScanOutcome.IndexFolderMissing)
|
||||
{
|
||||
Console.WriteLine($"Index folder not found at: {result.IndexPath}");
|
||||
return 1;
|
||||
}
|
||||
|
||||
Console.WriteLine($"\n========== UpdateBlogPaths summary ==========");
|
||||
Console.WriteLine($"Metadata files: {result.MetadataFiles}");
|
||||
Console.WriteLine($"TTFolderPath written: {result.Written}");
|
||||
Console.WriteLine($"Already correct: {result.Unchanged}");
|
||||
Console.WriteLine($"No FileDownloadLocation: {result.NoLocation}");
|
||||
Console.WriteLine($"No matching blog row: {result.NoMatchingRow}");
|
||||
Console.WriteLine($"Errors: {result.Errors}");
|
||||
|
||||
Console.WriteLine($"\nBlogs now holding a TTFolderPath: {DataAccess.CountBlogsWithTTFolderPath()}");
|
||||
|
||||
return result.Errors == 0 ? 0 : 2;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -142,6 +142,9 @@ ORDER BY et.tbl;
|
||||
-- 1c. EXTRA / UNEXPECTED COLUMNS: present in the DB but not in the expected
|
||||
-- list above. Informational only -- e.g. a NEWER backup, or a column this
|
||||
-- script's expected-list hasn't been updated for. Not an error by itself.
|
||||
-- Posts.IsActive and Notes.IsActive are listed here and NOT in 1a on
|
||||
-- purpose: they are written by other tools, the app only reads them when
|
||||
-- present, and it must not be told to add them. See TL.db.md.
|
||||
WITH expected(tbl, col) AS (
|
||||
VALUES
|
||||
('Posts','BlogName'),('Posts','PostID'),('Posts','HasNotesGathered'),('Posts','reblogURL'),
|
||||
@@ -150,14 +153,14 @@ WITH expected(tbl, col) AS (
|
||||
('Posts','Quote'),('Posts','Body'),('Posts','Tags'),('Posts','Link'),('Posts','PhotoURL'),
|
||||
('Posts','PhotoCaption'),('Posts','DownloadedFiles'),('Posts','AudioCaption'),('Posts','Question'),
|
||||
('Posts','Answer'),('Posts','Title'),('Posts','ByLikes'),('Posts','RootBlogName'),('Posts','RootURL'),
|
||||
('Posts','DateModified'),('Posts','DateCreated'),('Posts','PostType'),
|
||||
('Posts','DateModified'),('Posts','DateCreated'),('Posts','PostType'),('Posts','IsActive'),
|
||||
('Blogs','BlogName'),('Blogs','HasBeenOutput'),('Blogs','IsActive'),('Blogs','DateAdded'),
|
||||
('Blogs','ByLikes'),('Blogs','DateModified'),('Blogs','DateCreated'),('Blogs','LikesPulled'),
|
||||
('Blogs','LikesCursor'),('Blogs','LikesNewestTimestamp'),('Blogs','LikesLastRefreshed'),
|
||||
('Blogs','LikesLastNewCount'),('Blogs','TTFolderPath'),
|
||||
('Notes','RootBlogName'),('Notes','PostID'),('Notes','NoteBlogName'),('Notes','TimeStamp'),
|
||||
('Notes','Type'),('Notes','DatetimeCrawled'),('Notes','DateModified'),('Notes','DateCreated'),
|
||||
('Notes','replyText'),
|
||||
('Notes','replyText'),('Notes','IsActive'),
|
||||
('DailyAPICount','Date'),('DailyAPICount','APICount'),
|
||||
('ApiKeyPoolState','KeyName'),('ApiKeyPoolState','RetryUntil'),
|
||||
('ApiKeyPoolMeta','Id'),('ApiKeyPoolMeta','LastIndex')
|
||||
|
||||
Reference in New Issue
Block a user