From f0b113cf9739519bacd42aaa5fa3cc8aee0cd030 Mon Sep 17 00:00:00 2001 From: Cameron Pfiffer Date: Mon, 9 Mar 2026 22:29:20 -0700 Subject: [PATCH] feat: add fetch-history command to pull historical corpora for stylometric analysis --- src/void_tools/cli.py | 65 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 65 insertions(+) diff --git a/src/void_tools/cli.py b/src/void_tools/cli.py index cc83674..a4c9d40 100644 --- a/src/void_tools/cli.py +++ b/src/void_tools/cli.py @@ -769,6 +769,62 @@ def cmd_ingest_thread(args): except Exception as e: print(f"Error ingesting thread: {e}", file=sys.stderr) +def cmd_fetch_history(args): + client = get_client() + try: + profile = client.app.bsky.actor.get_profile({'actor': args.handle}) + did = profile.did + + posts = [] + cursor = None + + import datetime + from dateutil import parser as date_parser + + target_date = date_parser.isoparse(args.before) + if target_date.tzinfo is None: + target_date = target_date.replace(tzinfo=datetime.UTC) + + print(f"Fetching posts for {args.handle} before {target_date}...") + + while len(posts) < args.limit: + kwargs = {'actor': did, 'limit': 100} + if cursor: + kwargs['cursor'] = cursor + + response = client.app.bsky.feed.get_author_feed(kwargs) + + if not response.feed: + break + + for item in response.feed: + # We only want posts by the author, not reposts + if hasattr(item.post.record, 'text') and item.post.author.did == did: + post_time = date_parser.isoparse(item.post.indexed_at) + if post_time < target_date: + posts.append({ + 'uri': item.post.uri, + 'text': item.post.record.text, + 'indexed_at': item.post.indexed_at + }) + + if len(posts) >= args.limit: + break + + if not response.cursor: + break + cursor = response.cursor + + import json + out_path = os.path.expanduser(os.path.join("~/void", args.out)) + with open(out_path, 'w') as f: + json.dump(posts, f, indent=2) + + print(f"Successfully fetched {len(posts)} posts and saved to {out_path}") + + except Exception as e: + print(f"Error fetching history: {e}", file=sys.stderr) + def main(): parser = argparse.ArgumentParser(description="Void ATProto CLI Tools") subparsers = parser.add_subparsers(dest="command", required=True) @@ -795,6 +851,13 @@ def main(): ingest_parser = subparsers.add_parser("ingest-thread", help="Download an entire post thread and save it to MemFS") ingest_parser.add_argument("uri", help="The AT-URI of the root or target post") ingest_parser.add_argument("--depth", type=int, default=10, help="Maximum depth of replies to fetch") + + # Fetch History + history_parser = subparsers.add_parser("fetch-history", help="Fetch a user's historical posts before a specific date") + history_parser.add_argument("handle", help="The user handle to fetch (e.g., void.comind.network)") + history_parser.add_argument("--before", help="ISO format date (e.g., 2026-02-20T00:00:00Z)", required=True) + history_parser.add_argument("--limit", type=int, default=1000, help="Maximum number of posts to fetch") + history_parser.add_argument("--out", help="Output JSON file path", default="historical_corpus.json") # Profile profile_parser = subparsers.add_parser("profile", help="Manage profile") @@ -831,6 +894,8 @@ def main(): cmd_search(args) elif args.command == "ingest-thread": cmd_ingest_thread(args) + elif args.command == "fetch-history": + cmd_fetch_history(args) elif args.command == "profile": cmd_profile(args) elif args.command == "annotate": -- 2.51.2