Scraping Historical X (Twitter) Posts via Firefox Console
The Motivation
There’s a car reviewer I used to follow religiously. They didn’t review high-end cars – just ordinary, affordable cars. The kind you’d actually consider buying. But what made their videos special was the level of detail: the walkarounds, the trim and engine breakdown, the honest takes on what it’s like to live with a car day to day.
For me, they were the ultimate window-shopping companion. When I wanted to daydream about an ex-Japanese import — a Honda Jade, a Mitsubishi Delica, a Subaru forester SG5 — I’d go back to their reviews and imagine myself behind the wheel.
Then one day, those videos were gone. Deleted, unlisted, wiped from the channel. I wanted to know what else had disappeared. And I had a hunch: before each video release, this reviewer used to post teasers on X (Twitter). If I could find those old posts, I could piece together which videos had once existed.
The catch? Scrolling through years of tweets manually wasn’t realistic. Third-party scraping tools wanted money, and paid APIs were off the table. So I opened the Firefox console and built my own scraper.
Why X Search Is Trickier Than It Looks
Standard search queries like youtube.com often miss results on X because the platform rewrites every external link into its internal shortener: t.co. Searching for the raw domain returns almost nothing.
The fix is X’s native filter flags. Here’s the query that worked:
1
from:carnversations filter:links until:2024-03-25 -filter:retweets
| Operator | Function | Description |
|---|---|---|
from:USERNAME | Author Filter | Scopes the search strictly to posts created by @carnversations. |
filter:links | Media Filter | Matches posts containing any URL — including shortened t.co links. |
until:YYYY-MM-DD | Date Upper Bound | Exclusive cutoff. until:2024-03-25 includes posts up to 23:59:59 UTC on March 24, 2024. |
-filter:retweets | Exclusion | Excludes native retweets, returning only original tweets and direct replies. |
Crucial step: After running the search, switch from the Top tab to the Latest tab. This presents tweets in strict reverse chronological order, letting the script scroll cleanly backward through time.
Why Scraping X Is Hard
Modern single-page apps like X don’t behave like static websites. Two challenges stand out:
- DOM Recycling: X unmounts off-screen DOM nodes as you scroll to keep browser memory in check. If you aren’t capturing elements as they appear, they vanish.
- Obfuscated CSS Classes: Class names like
.r-18u37izare dynamically generated at build time and change frequently. Targeting them is a losing game.
The solution:
- Stable Selector Targets — Target X’s internal
data-testidattributes, which stay consistent across deployments. - Key-Based Deduplication — Use each tweet’s unique status URL as a key in a JavaScript
Map(), so re-encountered nodes update rather than duplicate. - Async Scroll & Render Loop — Trigger window scrolling with
awaitpauses so X’s virtual DOM has time to fetch and render the next chunk.
The Scraper Script
Open Firefox Developer Tools (F12 or Ctrl + Shift + K), paste the script into the Console tab, and press Enter.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
/**
* Dynamic DOM Scraper for X (Twitter) Search Results
* Optimized for Firefox Developer Console
*/
(async () => {
// 1. Data Structure Setup
// Map keyed by tweet URL prevents duplicates as elements scroll in/out of DOM
const scrapedData = new Map();
// Execution Configuration
const SCROLL_CYCLES = 50; // Total number of scroll operations
const SCROLL_DISTANCE = 1000; // Vertical scroll distance per cycle (pixels)
const PAUSE_DELAY_MS = 1500; // Wait time for network fetch & DOM render
console.log(`[+] Initializing scraper: ${SCROLL_CYCLES} cycles scheduled.`);
// Utility helper for delaying execution in async workflow
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
// 2. DOM Parser Function
function extractVisibleTweets() {
// Select all currently mounted tweet article components
const tweetElements = document.querySelectorAll('article[data-testid="tweet"]');
tweetElements.forEach((tweetEl) => {
// Locate time element (contains ISO timestamp string)
const timeEl = tweetEl.querySelector('time');
// Locate text wrapper container
const textEl = tweetEl.querySelector('div[data-testid="tweetText"]');
// Extract permalink from the anchor wrapping the time element
const permalinkEl = timeEl ? timeEl.closest('a') : null;
if (permalinkEl && permalinkEl.href) {
const tweetUrl = permalinkEl.href;
const isoDate = timeEl ? timeEl.getAttribute('datetime') : '';
// Normalize text string by converting newlines to spaces
const rawText = textEl ? textEl.innerText.replace(/\n/g, ' ') : '';
// Extract internal links (t.co shorteners) contained within the post body
const embeddedUrls = [];
tweetEl.querySelectorAll('a[href*="t.co"]').forEach((anchor) => {
if (anchor.href) embeddedUrls.push(anchor.href);
});
// Write or update entry in the Map
scrapedData.set(tweetUrl, {
timestamp: isoDate,
url: tweetUrl,
text: rawText,
links: embeddedUrls.join(', ')
});
}
});
}
// 3. Main Scraping Loop
for (let cycle = 1; cycle <= SCROLL_CYCLES; cycle++) {
// Parse visible elements before scrolling
extractVisibleTweets();
// Scroll browser window down
window.scrollBy(0, SCROLL_DISTANCE);
// Logging checkpoint every 10 iterations
if (cycle % 10 === 0 || cycle === SCROLL_CYCLES) {
console.log(`[+] Cycle ${cycle}/${SCROLL_CYCLES}: ${scrapedData.size} unique posts recorded.`);
}
// Pause execution to allow DOM hydration
await sleep(PAUSE_DELAY_MS);
}
// 4. Data Formatting and Export
const dataset = Array.from(scrapedData.values());
console.log(`[✔] Extraction complete. Total items: ${dataset.length}`);
// Create an in-memory JSON file and trigger browser download
const jsonBlob = new Blob([JSON.stringify(dataset, null, 2)], { type: 'application/json' });
const downloadLink = document.createElement('a');
downloadLink.href = URL.createObjectURL(jsonBlob);
downloadLink.download = `x_scraped_tweets_${Date.now()}.json`;
// Programmatically click link to download
downloadLink.click();
// Clean up object URL memory reference
URL.revokeObjectURL(downloadLink.href);
})();
Output Schema
The scraper exports a JSON array of objects:
1
2
3
4
5
6
7
8
[
{
"timestamp": "2022-08-15T14:30:00.000Z",
"url": "https://x.com/carnversations/status/1559187364123456789",
"text": "The 0-100 on the Honda jade RS was such a thrilling experience... https://t.co/example",
"links": "https://t.co/example"
}
]
Each entry captures the timestamp, permalink, cleaned post text, and any embedded links.
Cleaning the Data with Go
Raw scraped text is messy — links are often broken up by spaces, and there’s a lot of noise mixed in. I wrote a small Go program to extract only the YouTube links from each tweet and produce a clean, focused dataset.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
package main
import (
"encoding/json"
"fmt"
"os"
"regexp"
"strings"
)
// RawItem represents the structure of the input JSON
type RawItem struct {
Date string `json:"date"`
TweetURL string `json:"tweet_url"`
Text string `json:"text"`
Links string `json:"links"`
}
// CleanItem represents the desired cleaned output schema
type CleanItem struct {
Date string `json:"date"`
VideoLink string `json:"video_link"`
Description string `json:"description"`
}
func main() {
// 1. Read raw JSON file
fileData, err := os.ReadFile("raw_tweets.json")
if err != nil {
fmt.Printf("Error reading input file: %v\n", err)
return
}
var rawItems []RawItem
if err := json.Unmarshal(fileData, &rawItems); err != nil {
fmt.Printf("Error unmarshaling JSON: %v\n", err)
return
}
// 2. Compile Regex to catch YouTube links (including those with spaces)
ytRegex := regexp.MustCompile(`(?i)https?://\s*(?:www\.)?(?:youtube\.com/[^\s]+|youtu\.be/[^\s]+)`)
spaceRegex := regexp.MustCompile(`\s+`)
var cleanedData []CleanItem
// 3. Process items
for _, item := range rawItems {
match := ytRegex.FindString(item.Text)
if match != "" {
// Remove spaces inside the matched URL
videoLink := strings.ReplaceAll(match, " ", "")
// Clean description by removing the URL and collapsing extra whitespace
description := ytRegex.ReplaceAllString(item.Text, "")
description = strings.TrimSpace(spaceRegex.ReplaceAllString(description, " "))
cleanedData = append(cleanedData, CleanItem{
Date: item.Date,
VideoLink: videoLink,
Description: description,
})
}
}
// 4. Export cleaned JSON
outputJSON, err := json.MarshalIndent(cleanedData, "", " ")
if err != nil {
fmt.Printf("Error marshaling output JSON: %v\n", err)
return
}
if err := os.WriteFile("cleaned_videos.json", outputJSON, 0644); err != nil {
fmt.Printf("Error writing file: %v\n", err)
return
}
fmt.Printf("Success! Extracted %d valid YouTube videos.\n", len(cleanedData))
}
This pulls out the YouTube link from each tweet, strips the URL from the description, and outputs a tidy cleaned_videos.json:
1
2
3
4
5
6
7
[
{
"date": "2022-08-15T14:30:00.000Z",
"video_link": "https://youtu.be/example",
"description": "The 0-100 on the Honda jade RS was such a thrilling experience..."
}
]
Exploring the Dataset with Go
Once the data was clean, I ran a quick exploratory analysis in Go: date range, word counts, and the most common keywords across all descriptions. It gave me a sense of what kinds of cars and themes showed up most often.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
/*
eda_analysis.go
Perform dataset exploration and word count analysis on parsed video metadata.
*/
package main
import (
"encoding/json"
"fmt"
"os"
"regexp"
"sort"
"strings"
"time"
)
type VideoRecord struct {
Date string `json:"date"`
VideoLink string `json:"video_link"`
Description string `json:"description"`
}
type WordCount struct {
Word string
Count int
}
func main() {
// 1. Read Cleaned JSON
fileData, err := os.ReadFile("cleaned_videos.json")
if err != nil {
fmt.Printf("Error reading file: %v\n", err)
return
}
var records []VideoRecord
if err := json.Unmarshal(fileData, &records); err != nil {
fmt.Printf("Error unmarshaling JSON: %v\n", err)
return
}
if len(records) == 0 {
fmt.Println("No records to process.")
return
}
// 2. Variables for Stats
var totalWords int
minWords, maxWords := 1<<31-1, 0
var minTime, maxTime time.Time
wordMap := make(map[string]int)
wordRegex := regexp.MustCompile(`\b\w+\b`)
stopWords := map[string]bool{"the": true, "a": true, "to": true, "and": true, "of": true, "in": true, "is": true, "on": true, "for": true, "this": true}
// 3. Iterative Analysis
for i, r := range records {
t, _ := time.Parse(time.RFC3339, r.Date)
if i == 0 || t.Before(minTime) {
minTime = t
}
if i == 0 || t.After(maxTime) {
maxTime = t
}
// Text Stats
words := wordRegex.FindAllString(strings.ToLower(r.Description), -1)
wc := len(words)
totalWords += wc
if wc < minWords {
minWords = wc
}
if wc > maxWords {
maxWords = wc
}
// Track keywords
for _, w := range words {
if !stopWords[w] && len(w) > 2 {
wordMap[w]++
}
}
}
// 4. Sort Top Keywords
var wordCounts []WordCount
for k, v := range wordMap {
wordCounts = append(wordCounts, WordCount{Word: k, Count: v})
}
sort.Slice(wordCounts, func(i, j int) bool {
return wordCounts[i].Count > wordCounts[j].Count
})
// 5. Print Results
fmt.Println("================ EDA SUMMARY REPORT ================")
fmt.Printf("Total Videos Analyzed : %d\n", len(records))
fmt.Printf("Date Range : %s to %s\n", minTime.Format("2006-01-02"), maxTime.Format("2006-01-02"))
fmt.Println("\n--- Description Text Metrics ---")
fmt.Printf("Avg Word Count : %.2f words\n", float64(totalWords)/float64(len(records)))
fmt.Printf("Max Word Count : %d words\n", maxWords)
fmt.Printf("Min Word Count : %d words\n", minWords)
fmt.Println("\n--- Top Keywords / Themes ---")
for i := 0; i < 5 && i < len(wordCounts); i++ {
fmt.Printf(" - %s: %d occurrences\n", wordCounts[i].Word, wordCounts[i].Count)
}
fmt.Println("====================================================")
}
A sample run looked something like this:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
================ EDA SUMMARY REPORT ================
Total Videos Analyzed : 87
Date Range : 2019-04-12 to 2024-03-24
--- Description Text Metrics ---
Avg Word Count : 42.31 words
Max Word Count : 118 words
Min Word Count : 9 words
--- Top Keywords / Themes ---
- honda: 23 occurrences
- toyota: 19 occurrences
- review: 17 occurrences
- jade: 11 occurrences
- fielder: 8 occurrences
====================================================
It was a neat little confirmation: the reviewer really did focus on the everyday, affordable Japanese imports I remembered — and now I had a paper trail of everything they’d once covered.
What I Found
Running this against the reviewer’s timeline surfaced a long trail of teaser posts — release announcements, behind-the-scenes clips, and direct links to videos that no longer exist on their channel. Some had been replaced by re-uploads; others had vanished entirely. The scrape gave me the receipts: a documented record of what had once been published, and a clear view of what was quietly removed.
It’s a small win, but it scratches an itch every car enthusiast knows: when the reviews you used to window-shop with disappear, you want to know what disappeared — and now I do.
Enjoy Reading This Article?
Here are some more articles you might like to read next: