The Scraping Sandbox: Exporting Your Own X Archive to Fine-Tune Personal Writing Models Without Public API Costs

Relying on long, token-heavy system prompts to get an AI assistant to write like you is an expensive exercise in diminishing returns. The prompts eventually break, the model drifts, and you spend more time editing than shipping.

Using real-time web scrapers or paying for X’s basic API tier—which costs $100 per month—is equally inefficient if you simply want a historical snapshot of your own voice. Instead, you can bypass public API limits, network instability, and subscription costs entirely by using your official X data archive to train a local model.

This process involves downloading your account’s full historical data, parsing and filtering the deeply nested JavaScript objects, and formatting the cleaned text into structured pairs for local training. By feeding this custom dataset into a highly efficient fine-tuning library, you can generate a personalized model that naturally mimics your cadence, sentence structure, and vocabulary.

Exporting Your Raw Historical Data

Your historical post data is available for export directly through the platform. X packages this information inside an official data archive that includes every post, reply, and media asset associated with your profile since your account was created.

  1. Log in to X on a desktop browser.
  2. Navigate to More > Settings and privacy > Your account.
  3. Click on Download an archive of your data.
  4. Enter your password and complete any required two-factor authentication.
  5. Click Request archive.

The platform takes anywhere from 24 to 48 hours to compile the export. Once you receive the email notification confirming your file is ready, download the .zip folder and extract it.

Inside the extracted file system, locate the data/ directory. This contains a series of JavaScript (.js) files prefixed with a browser-executable global variable mapping, rather than standard, raw JSON.

Stripping and Parsing the Tweet Payload

The file containing your post history is located at data/tweets.js. If you open it in a text editor, you will see that it begins with a JavaScript variable assignment before the JSON structure starts:

window.YTD.tweets.part0 = [ {
  "tweet" : {
    "retweeted" : false,
    "source" : "<a href=\"http://twitter.com/download/iphone\" rel=\"nofollow\">Twitter for iPhone</a>",
    "entities" : { ... },
    "favorite_count" : "12",
    "id_str" : "1234567890",
    "truncated" : false,
    "retweet_count" : "2",
    "id" : 1234567890,
    "created_at" : "Wed Oct 10 20:19:24 +0000 2018",
    "favorited" : false,
    "full_text" : "This is an example post containing raw text.",
    "lang" : "en"
  }
} ... ]

To parse this with Python, you must strip the leading JavaScript assignment so that the standard json library can read it as a raw list of dictionaries. You also need to clean the text to isolate your actual voice.

This means removing retweets (which represent other writers’ voices), stripping out URL links pointing to external sites or media attachments, and cleaning up mentions that clutter the text.

Here is a clean Python script that handles the entire extraction process:

import json
import re

def clean_tweet_text(text):
    # Remove standard t.co URLs
    text = re.sub(r'https://t\.co/\S+', '', text)
    # Clean up whitespace and newlines
    text = re.sub(r'\s+', ' ', text).strip()
    return text

def parse_archive(input_path, output_path):
    # Read file and skip the JS variable declaration at the start
    with open(input_path, 'r', encoding='utf-8') as f:
        content = f.read()

    # Find the starting bracket of the JSON array
    json_start = content.find('[')
    if json_start == -1:
        raise ValueError("Could not find the start of the JSON data array.")

    json_data = json.loads(content[json_start:])
    cleaned_posts = []

    for item in json_data:
        tweet = item.get('tweet', {})
        text = tweet.get('full_text', '')

        # Filter rules
        is_retweet = text.startswith('RT @')
        is_reply = tweet.get('in_reply_to_status_id') is not None

        # Retain original posts, excluding retweets and basic thread replies
        if not is_retweet and not is_reply and len(text) > 10:
            cleaned_text = clean_tweet_text(text)
            if cleaned_text:
                cleaned_posts.append(cleaned_text)

    # Save structured dataset
    with open(output_path, 'w', encoding='utf-8') as out_f:
        json.dump(cleaned_posts, out_f, indent=2)

    print(f"Extraction complete. Retained {len(cleaned_posts)} original posts.")

if __name__ == "__main__":
    parse_archive('data/tweets.js', 'cleaned_posts.json')

Transforming Raw Text into Fine-Tuning Templates

Large language models require specific conversational structures to learn style replication. Rather than raw paragraphs, training data is most effective when structured in Alpaca (Instruction-Input-Response) or ShareGPT (conversational turns) formats.

For stylistic mirroring, map a generic instruction to your cleaned posts as target outputs. This tells the model: “When asked to write in this specific style on a topic, produce this output structure.”

To automate formatting, we can write a short pipeline that processes cleaned_posts.json and creates an Alpaca-style JSON file:

import json

def format_to_alpaca(cleaned_json_path, alpaca_json_path):
    with open(cleaned_json_path, 'r', encoding='utf-8') as f:
        posts = json.load(f)

    alpaca_dataset = []

    for post in posts:
        # We can extract a short summary to act as the prompt input
        # or ask the model to generate a post of a certain style
        alpaca_dataset.append({
            "instruction": "Draft a short, compelling post in the author's signature style.",
            "input": f"Topic or core message: {post[:50]}...",
            "output": post
        })

    with open(alpaca_json_path, 'w', encoding='utf-8') as f:
        json.dump(alpaca_dataset, f, indent=2)

if __name__ == "__main__":
    format_to_alpaca('cleaned_posts.json', 'alpaca_dataset.json')

Local Model Fine-Tuning Using Unsloth

To run your training locally without renting multi-GPU clouds, use Unsloth, an open-source optimization library designed for local consumer hardware. Unsloth reduces memory footprints by utilizing optimized 4-bit model quantizations while maintaining style replication quality.

You can configure and run the local training script with an open-source base model like Meta’s Llama-3-8B. Ensure you run this inside an environment with an NVIDIA GPU.

import torch
from unsloth import FastLanguageModel
from datasets import load_dataset
from trl import SFTTrainer
from transformers import TrainingArguments

max_seq_length = 2048 # Adjust based on GPU memory limits
dtype = None # Auto-detect GPU architecture
load_in_4bit = True # Save local VRAM

# Load your starting model
model, tokenizer = FastLanguageModel.from_pretrained(
    model_name="unsloth/llama-3-8b-Instruct-bnb-4bit", # Pre-quantized baseline
    max_seq_length=max_seq_length,
    dtype=dtype,
    load_in_4bit=load_in_4bit,
)

# Apply LoRA adapters to freeze weights and isolate style parameters
model = FastLanguageModel.get_peft_model(
    model,
    r=16, # Rank of the adapter
    target_modules=["q_proj", "k_proj", "v_proj", "o_proj", "gate_proj", "up_proj", "down_proj"],
    lora_alpha=16,
    lora_dropout=0, # Optimized for 0% dropout to prevent slow compute
    bias="none",
    use_gradient_checkpointing="unsloth", # Offloads activation states to save VRAM
    random_state=3407,
    use_rslora=False,
)

# Define the formatting function for Alpaca template mapping
alpaca_prompt = """Below is an instruction that describes a task, paired with an input that provides further context. Write a response that appropriately completes the request.

### Instruction:
{}

### Input:
{}

### Response:
{}"""

def formatting_prompts_func(examples):
    instructions = examples["instruction"]
    inputs       = examples["input"]
    outputs      = examples["output"]
    texts = []
    for instruction, input_text, output in zip(instructions, inputs, outputs):
        text = alpaca_prompt.format(instruction, input_text, output)
        texts.append(text)
    return { "text" : texts }

# Load the local JSON dataset we prepared
dataset = load_dataset("json", data_files="alpaca_dataset.json", split="train")
dataset = dataset.map(formatting_prompts_func, batched=True)

# Run the Supervised Fine-Tuning (SFT) sequence
trainer = SFTTrainer(
    model=model,
    tokenizer=tokenizer,
    train_dataset=dataset,
    dataset_text_field="text",
    max_seq_length=max_seq_length,
    dataset_num_proc=2,
    packing=False, # Set to True for short structures to speed up batch packaging
    args=TrainingArguments(
        per_device_train_batch_size=2,
        gradient_accumulation_steps=4,
        warmup_steps=5,
        max_steps=60, # Run a short optimization step
        learning_rate=2e-4,
        fp16=not torch.cuda.is_bf16_supported(),
        bf16=torch.cuda.is_bf16_supported(),
        logging_steps=1,
        optim="adamw_8bit",
        weight_decay=0.01,
        lr_scheduler_type="linear",
        seed=3407,
        output_dir="outputs",
    ),
)

trainer_stats = trainer.train()

# Save the trained adapter parameters
model.save_pretrained("local_style_adapter")
tokenizer.save_pretrained("local_style_adapter")

Once the model has successfully run through training, the output adapter captures the structural rhythms of your historical writing. You can export these weights directly to a GGUF format to run locally on consumer machines using desktop LLM managers like Ollama—giving you a dedicated writing companion with zero external API dependencies or subscription overheads.


This article was generated with the help of AI.

This post was generated by Omniposter AI. Start your free trial.