ATlast — you'll never need to find your favorites on another platform again. Find your favs in the ATmosphere.
atproto
1# ATlast Twitter/X Support Plan
2
3## Current Status (2025-12-27)
4
5**Phase 1 Status:** ✅ COMPLETE - Ready for production testing and Chrome Web Store submission
6
7**All Completed (Dec 2024 - Jan 2025):**
8- ✅ Environment configuration (dev/prod builds with correct API URLs)
9- ✅ Server health check and offline state handling
10- ✅ Authentication flow (session check before upload)
11- ✅ Removed temporary storage approach (extension_imports table)
12- ✅ Refactored to require login first (matches file upload flow)
13- ✅ Fixed NaN database error (missing matchedUsers parameter)
14- ✅ Database initialized for dev environment
15- ✅ Fixed API response unwrapping (uploadToATlast and checkSession)
16- ✅ Loading screen during extension upload search
17- ✅ Timezone fixes with TIMESTAMPTZ
18- ✅ Vite dev server optimization
19- ✅ Decision graph integrity fixes (18 orphan nodes resolved)
20- ✅ Documentation improvements (CLAUDE.md with lifecycle management)
21
22**Ready For:**
23- Production testing
24- Chrome Web Store submission
25- Firefox Add-ons development
26
27**Decision Graph:** 332 nodes, 333 edges - [View live graph](https://notactuallytreyanastasio.github.io/deciduous/)
28
29---
30
31## Problem Statement
32
33Twitter/X data exports only contain `user_id` values, not usernames. Example:
34```
35https://twitter.com/intent/user?user_id=1103954565026775041
36```
37
38This makes data export files unusable for our existing parser-based workflow. We need a live scraping approach to extract usernames from the user's Following page.
39
40## Research Findings
41
42### Why Data Export Doesn't Work
43- Twitter exports contain only numeric `user_id` in URLs
44- Resolving `user_id` → `screen_name` requires API access ($42k/year Enterprise tier) or scraping
45- Nitter is dead (Feb 2024) - Twitter killed guest accounts
46- Third-party ID lookup tools don't support bulk/API access
47
48### Live Scraping Approach
49Users are typically logged into Twitter. We can scrape usernames directly from the DOM of `x.com/following` using stable selectors:
50- `[data-testid="UserName"]` - stable, recommended
51- CSS class selectors - volatile, change frequently
52
53### Platform Support Matrix
54
55| Platform | Extension Support | Bookmarklet JS | Solution |
56|----------|------------------|----------------|----------|
57| Desktop Chrome/Edge | Full | Yes | WebExtension |
58| Desktop Firefox | Full | Yes | WebExtension |
59| Desktop Safari | Full | Yes | WebExtension |
60| Android Firefox | Full | Yes | WebExtension |
61| Android Chrome | None | Via address bar | Recommend Firefox |
62| iOS Safari | Via App Store app | Blocked since iOS 15 | Safari Web Extension |
63
64### iOS-Specific Findings
65
66**iOS Shortcuts "Run JavaScript on Webpage":**
67- CAN access authenticated Safari session via Share Sheet
68- BUT has strict timeout (few seconds)
69- Infinite scroll would timeout immediately
70- Only viable for grabbing currently visible content
71
72**iOS Safari Web Extensions (iOS 15+):**
73- Uses same WebExtensions API as Chrome/Firefox
74- Content scripts run without timeout limits
75- REQUIRES App Store distribution as part of iOS app
76- Full capability: auto-scroll, scrape, upload
77
78## Architecture Decisions
79
80### Monorepo Structure (pnpm workspaces)
81
82```
83ATlast/
84├── pnpm-workspace.yaml
85├── package.json # Root workspace config
86├── packages/
87│ ├── web/ # Existing web app (moved from src/)
88│ │ ├── src/
89│ │ ├── package.json
90│ │ └── vite.config.ts
91│ ├── extension/ # ATlast Importer browser extension
92│ │ ├── src/
93│ │ ├── manifest.json
94│ │ ├── package.json
95│ │ └── build.config.ts
96│ ├── shared/ # Shared types and utilities
97│ │ ├── src/
98│ │ │ ├── types/
99│ │ │ │ ├── platform.ts # Platform enum, configs
100│ │ │ │ ├── import.ts # Import request/response types
101│ │ │ │ └── index.ts
102│ │ │ └── utils/
103│ │ │ └── username.ts # Username normalization
104│ │ └── package.json
105│ └── functions/ # Netlify functions (moved from netlify/)
106│ ├── src/
107│ ├── package.json
108│ └── tsconfig.json
109├── netlify.toml
110└── docs/ # Decision graph output
111```
112
113### Extension Name
114**ATlast Importer** - Clear purpose, searchable in extension stores.
115
116### WebExtension Targets
117- Chrome/Edge (Manifest V3)
118- Firefox (Manifest V2/V3)
119- Safari (desktop + iOS via App Store wrapper) - deferred
120
121---
122
123## Extension Architecture
124
125### High-Level Flow
126
127```
128┌─────────────────────────────────────────────────────────────────┐
129│ ATlast Browser Extension │
130├─────────────────────────────────────────────────────────────────┤
131│ │
132│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
133│ │ Popup UI │ │ Content │ │ Background │ │
134│ │ │◄──►│ Script │◄──►│ Service │ │
135│ │ - Status │ │ │ │ Worker │ │
136│ │ - Progress │ │ - Scrape │ │ │ │
137│ │ - Actions │ │ - Scroll │ │ - Storage │ │
138│ └──────────────┘ │ - Collect │ │ - Messaging │ │
139│ └──────────────┘ └──────────────┘ │
140│ │
141└─────────────────────────────────────────────────────────────────┘
142 │
143 ▼
144 ┌──────────────────┐
145 │ ATlast Web App │
146 │ │
147 │ - Receive data │
148 │ - Search Bsky │
149 │ - Show matches │
150 └──────────────────┘
151```
152
153### Component Breakdown
154
155#### 1. Manifest Configuration
156```
157extension/
158├── manifest.json # Extension manifest (V3 for Chrome, V2 for Firefox)
159├── manifest.firefox.json # Firefox-specific overrides (if needed)
160└── manifest.safari.json # Safari-specific overrides (if needed)
161```
162
163#### 2. Content Script (`content.js`)
164Injected into `x.com` / `twitter.com` pages.
165
166**Responsibilities:**
167- Detect if on Following/Followers page
168- Auto-scroll to load all users
169- Extract usernames using `[data-testid="UserName"]`
170- Report progress to popup/background
171- Handle rate limiting and pagination
172
173**Scraping Logic (pseudo-code):**
174```javascript
175async function scrapeFollowing() {
176 const usernames = new Set();
177 let lastCount = 0;
178 let stableCount = 0;
179
180 while (stableCount < 3) { // Stop after 3 scrolls with no new users
181 // Collect visible usernames
182 document.querySelectorAll('[data-testid="UserName"]').forEach(el => {
183 const username = extractUsername(el);
184 if (username) usernames.add(username);
185 });
186
187 // Report progress
188 sendProgress(usernames.size);
189
190 // Scroll down
191 window.scrollBy(0, 1000);
192 await sleep(500);
193
194 // Check if we found new users
195 if (usernames.size === lastCount) {
196 stableCount++;
197 } else {
198 stableCount = 0;
199 lastCount = usernames.size;
200 }
201 }
202
203 return Array.from(usernames);
204}
205```
206
207#### 3. Popup UI (`popup.html`, `popup.js`)
208User interface when clicking extension icon.
209
210**States:**
211- **Inactive**: "Go to x.com/following to start"
212- **Ready**: "Found Following page. Click to scan."
213- **Scanning**: Progress bar, count of found users
214- **Complete**: "Found 847 users. Open in ATlast"
215- **Error**: Error message with retry option
216
217#### 4. Background Service Worker (`background.js`)
218Coordinates between content script and popup.
219
220**Responsibilities:**
221- Store scraped data temporarily
222- Handle cross-tab communication
223- Manage extension state
224- Generate handoff URL/data for ATlast
225
226### Data Handoff to ATlast
227
228**Decision: POST to API endpoint**
229
230Extension will POST scraped usernames to a new Netlify function endpoint.
231
232```
233┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
234│ Extension │ POST │ Netlify Func │ Store │ Database │
235│ │────────►│ /extension- │────────►│ │
236│ usernames[] │ │ import │ │ pending_import │
237│ platform: "x" │ │ │ │ │
238└─────────────────┘ └────────┬────────┘ └─────────────────┘
239 │
240 │ Returns: { importId: "abc123" }
241 ▼
242 ┌─────────────────┐
243 │ Redirect to │
244 │ atlast.app/ │
245 │ import/abc123 │
246 └─────────────────┘
247```
248
249**API Endpoint: `POST /extension-import`**
250
251Request:
252```json
253{
254 "platform": "twitter",
255 "usernames": ["user1", "user2", ...],
256 "metadata": {
257 "extensionVersion": "1.0.0",
258 "scrapedAt": "2024-01-15T...",
259 "pageType": "following"
260 }
261}
262```
263
264Response:
265```json
266{
267 "importId": "abc123",
268 "redirectUrl": "https://atlast.app/import/abc123"
269}
270```
271
272**Why POST over other options:**
273- No URL length limits (supports 10k+ usernames)
274- Secure (HTTPS, can add rate limiting)
275- Seamless UX (extension opens ATlast directly)
276- Audit trail (imports stored in database)
277
278### Extension Package Structure (`packages/extension/`)
279
280```
281packages/extension/
282├── manifest.json # Base manifest (Chrome MV3)
283├── manifest.firefox.json # Firefox overrides (if needed)
284├── package.json # Extension package config
285├── tsconfig.json
286├── build.config.ts # Build script config
287├── src/
288│ ├── content/
289│ │ ├── scrapers/
290│ │ │ ├── base-scraper.ts # Abstract base class
291│ │ │ ├── twitter-scraper.ts # Twitter/X implementation
292│ │ │ ├── threads-scraper.ts # (Future) Threads
293│ │ │ ├── instagram-scraper.ts # (Future) Instagram
294│ │ │ └── tiktok-scraper.ts # (Future) TikTok
295│ │ ├── scroll-handler.ts # Generic infinite scroll
296│ │ └── index.ts # Content script entry, platform detection
297│ ├── popup/
298│ │ ├── popup.html
299│ │ ├── popup.css
300│ │ └── popup.ts
301│ ├── background/
302│ │ └── service-worker.ts
303│ └── lib/
304│ ├── messaging.ts # Extension messaging
305│ ├── storage.ts # chrome.storage wrapper
306│ └── api-client.ts # POST to ATlast API
307├── assets/
308│ ├── icon-16.png
309│ ├── icon-48.png
310│ └── icon-128.png
311└── dist/
312 ├── chrome/ # Built extension for Chrome
313 ├── firefox/ # Built extension for Firefox
314 └── chrome.zip # Store submission package
315```
316
317### Shared Package Structure (`packages/shared/`)
318
319```
320packages/shared/
321├── package.json
322├── tsconfig.json
323├── src/
324│ ├── types/
325│ │ ├── platform.ts # Platform enum, URL patterns
326│ │ ├── import.ts # ExtensionImportRequest, ExtensionImportResponse
327│ │ ├── scraper.ts # ScraperResult, ScraperProgress
328│ │ └── index.ts # Re-exports
329│ ├── utils/
330│ │ ├── username.ts # normalizeUsername(), validateUsername()
331│ │ └── index.ts
332│ └── index.ts # Main entry
333└── dist/ # Compiled output
334```
335
336### Shared Types Example
337
338```typescript
339// packages/shared/src/types/platform.ts
340export enum Platform {
341 Twitter = 'twitter',
342 Threads = 'threads',
343 Instagram = 'instagram',
344 TikTok = 'tiktok',
345}
346
347export interface PlatformConfig {
348 platform: Platform;
349 displayName: string;
350 hostPatterns: string[];
351 followingPathPattern: RegExp;
352 iconUrl: string;
353}
354
355export const PLATFORM_CONFIGS: Record<Platform, PlatformConfig> = {
356 [Platform.Twitter]: {
357 platform: Platform.Twitter,
358 displayName: 'Twitter/X',
359 hostPatterns: ['twitter.com', 'x.com'],
360 followingPathPattern: /^\/[^/]+\/following$/,
361 iconUrl: 'https://abs.twimg.com/favicons/twitter.ico',
362 },
363 // ... future platforms
364};
365```
366
367```typescript
368// packages/shared/src/types/import.ts
369import { Platform } from './platform';
370
371export interface ExtensionImportRequest {
372 platform: Platform;
373 usernames: string[];
374 metadata: {
375 extensionVersion: string;
376 scrapedAt: string;
377 pageType: 'following' | 'followers' | 'list';
378 sourceUrl: string;
379 };
380}
381
382export interface ExtensionImportResponse {
383 importId: string;
384 usernameCount: number;
385 redirectUrl: string;
386}
387```
388
389### Platform Detection & Extensibility
390
391Content script detects platform from URL and loads appropriate scraper:
392
393```javascript
394// src/content/index.js
395const PLATFORM_PATTERNS = {
396 twitter: {
397 hostPatterns: ['twitter.com', 'x.com'],
398 followingPath: /^\/[^/]+\/following$/,
399 scraper: () => import('./scrapers/twitter-scraper.js')
400 },
401 threads: {
402 hostPatterns: ['threads.net'],
403 followingPath: /^\/[^/]+\/following$/,
404 scraper: () => import('./scrapers/threads-scraper.js')
405 },
406 // ... future platforms
407};
408
409function detectPlatform() {
410 const host = window.location.hostname;
411 const path = window.location.pathname;
412
413 for (const [name, config] of Object.entries(PLATFORM_PATTERNS)) {
414 if (config.hostPatterns.some(h => host.includes(h))) {
415 if (config.followingPath.test(path)) {
416 return { platform: name, pageType: 'following', ...config };
417 }
418 }
419 }
420 return null;
421}
422```
423
424### Base Scraper Interface
425
426```javascript
427// src/content/scrapers/base-scraper.js
428export class BaseScraper {
429 constructor(options = {}) {
430 this.onProgress = options.onProgress || (() => {});
431 this.onComplete = options.onComplete || (() => {});
432 this.onError = options.onError || (() => {});
433 }
434
435 // Must be implemented by subclasses
436 getUsernameSelector() { throw new Error('Not implemented'); }
437 extractUsername(element) { throw new Error('Not implemented'); }
438
439 // Shared infinite scroll logic
440 async scrape() {
441 const usernames = new Set();
442 let stableCount = 0;
443
444 while (stableCount < 3) {
445 const before = usernames.size;
446
447 document.querySelectorAll(this.getUsernameSelector()).forEach(el => {
448 const username = this.extractUsername(el);
449 if (username) usernames.add(username);
450 });
451
452 this.onProgress({ count: usernames.size });
453
454 window.scrollBy(0, 1000);
455 await this.sleep(500);
456
457 stableCount = (usernames.size === before) ? stableCount + 1 : 0;
458 }
459
460 this.onComplete({ usernames: Array.from(usernames) });
461 return Array.from(usernames);
462 }
463
464 sleep(ms) {
465 return new Promise(resolve => setTimeout(resolve, ms));
466 }
467}
468```
469
470### Twitter Scraper Implementation
471
472```javascript
473// src/content/scrapers/twitter-scraper.js
474import { BaseScraper } from './base-scraper.js';
475
476export class TwitterScraper extends BaseScraper {
477 getUsernameSelector() {
478 // Primary selector (stable)
479 return '[data-testid="UserName"]';
480 }
481
482 extractUsername(element) {
483 // UserName element contains display name and @handle
484 // Structure: <div><span>Display Name</span></div><div><span>@handle</span></div>
485 const spans = element.querySelectorAll('span');
486 for (const span of spans) {
487 const text = span.textContent?.trim();
488 if (text?.startsWith('@')) {
489 return text.slice(1).toLowerCase(); // Remove @ prefix
490 }
491 }
492 return null;
493 }
494}
495```
496
497### iOS App Wrapper (Future)
498
499For iOS Safari extension, need minimal Swift app:
500
501```
502ATlastApp/
503├── ATlast/
504│ ├── ATlastApp.swift # Minimal app entry
505│ ├── ContentView.swift # Simple "Open Safari" UI
506│ └── Info.plist
507├── ATlast Extension/
508│ ├── SafariWebExtensionHandler.swift
509│ ├── Info.plist
510│ └── Resources/
511│ └── (same extension files as above)
512└── ATlast.xcodeproj
513```
514
515---
516
517## Decisions Made
518
519| Question | Decision | Rationale |
520|----------|----------|-----------|
521| **Data Handoff** | POST to API endpoint | No size limits, seamless UX, audit trail |
522| **MVP Scope** | Twitter Following page only | Fastest path to value |
523| **iOS Priority** | Deferred | Focus on desktop Chrome/Firefox first |
524| **Platform Scope** | Twitter v1, architecture for multi-platform | Plan for Threads/Instagram/TikTok later |
525| **Extension Name** | ATlast Importer | Clear purpose, searchable in stores |
526| **Code Location** | Monorepo with pnpm workspaces | Clean shared types, isolated builds |
527| **Monorepo Tool** | pnpm workspaces | Fast, disk-efficient, minimal config |
528
529## Remaining Questions
530
531### Q1: Extension Branding
532- Name options: "ATlast", "ATlast Importer", "ATlast Social Bridge"
533- Icon design needed
534
535### Q2: Error Recovery Strategy
536Twitter/X changes DOM frequently. Strategy for handling breaks:
537- Ship updates quickly when breaks detected
538- Build selector fallback chain
539- User-reportable "not working" flow
540- **Recommendation: All of the above**
541
542### Q3: Extension Store Distribution
543- Chrome Web Store (requires $5 developer fee)
544- Firefox Add-ons (free)
545- Safari Extensions (requires Apple Developer account, $99/year - defer with iOS)
546
547---
548
549## Implementation Phases
550
551### Phase 0: Monorepo Migration ✅ COMPLETE
552- [x] **0.1** Install pnpm globally if needed
553- [x] **0.2** Create pnpm-workspace.yaml
554- [x] **0.3** Create packages/ directory structure
555- [x] **0.4** Move src/ → packages/web/src/
556- [x] **0.5** Move netlify/functions/ → packages/functions/
557- [x] **0.6** Create packages/shared/ with types
558- [x] **0.7** Update import paths in web and functions
559- [x] **0.8** Update netlify.toml for new paths
560- [x] **0.9** Update root package.json scripts
561- [x] **0.10** Test build and dev commands
562- [x] **0.11** Commit monorepo migration
563
564### Phase 1: Chrome Extension MVP ✅ COMPLETE
565- [x] **1.1** Create packages/extension/ structure
566- [x] **1.2** Write manifest.json (Manifest V3)
567- [x] **1.3** Implement base-scraper.ts abstract class
568- [x] **1.4** Implement twitter-scraper.ts
569- [x] **1.5** Implement content/index.ts (platform detection)
570- [x] **1.6** Implement popup UI (HTML/CSS/TS)
571- [x] **1.7** Implement background service worker
572- [x] **1.8** Implement api-client.ts (POST to ATlast)
573- [x] **1.9** Create Netlify function: extension-import.ts
574- [x] **1.10** ~~Create ATlast import page: /import/[id]~~ (Not needed - uses /results?uploadId)
575- [x] **1.11** Add extension build script
576- [x] **1.12** Test end-to-end flow locally - All bugs resolved
577- [ ] **1.13** Chrome Web Store submission - Next step
578
579### Phase 2: Firefox Support
580- [ ] **2.1** Create manifest.firefox.json (MV2 if needed)
581- [ ] **2.2** Test on Firefox desktop
582- [ ] **2.3** Test on Firefox Android
583- [ ] **2.4** Firefox Add-ons submission
584
585### Phase 3: Enhanced Twitter Features
586- [ ] **3.1** Support Followers page
587- [ ] **3.2** Support Twitter Lists
588- [ ] **3.3** Add selector fallback chain
589- [ ] **3.4** Add user-reportable error flow
590
591### Phase 4: Additional Platforms (Future)
592- [ ] **4.1** Threads scraper
593- [ ] **4.2** Instagram scraper
594- [ ] **4.3** TikTok scraper
595
596### Phase 5: iOS Support (Future)
597- [ ] **5.1** iOS app wrapper (Swift)
598- [ ] **5.2** Safari Web Extension integration
599- [ ] **5.3** App Store submission
600
601---
602
603## Related Decision Graph Nodes
604
605- **Goal**: #184 (Support Twitter/X file uploads)
606- **Problem Analysis**: #185-186 (user_id issue, resolution approach decision)
607- **Initial Options**: #187-192 (server-side, extension, CLI, BYOK, hybrid)
608- **Research**: #193-204 (Nitter dead, Sky Follower Bridge, DOM scraping)
609- **iOS Research**: #212-216 (Shortcuts timeout, Safari Web Extensions)
610- **Architecture Decisions**: #218-222
611 - #219: POST to API endpoint
612 - #220: Twitter Following page MVP
613 - #221: iOS deferred
614 - #222: Multi-platform architecture
615- **Implementation Decisions**: #224-227
616 - #225: Monorepo with shared packages
617 - #226: Extension name "ATlast Importer"
618 - #227: pnpm workspaces tooling
619
620View live graph: https://notactuallytreyanastasio.github.io/deciduous/
621
622---
623
624## Changelog
625
626| Date | Change |
627|------|--------|
628| 2024-12-25 | Initial plan created with research findings and architecture |
629| 2024-12-25 | Decisions made: POST API, Twitter MVP, iOS deferred, extensible architecture |
630| 2024-12-25 | Added: Extension name (ATlast Importer), monorepo structure (pnpm workspaces) |
631| 2024-12-25 | Added: Phase 0 (monorepo migration), detailed package structures, shared types |
632| 2025-12-26 | Phase 0 complete (monorepo migration) |
633| 2025-12-26 | Phase 1 nearly complete - core implementation done, active debugging |
634| 2025-12-26 | Architecture refactored: extension requires login first, uses /results?uploadId |
635| 2025-12-26 | Fixed: NaN database error, environment config, auth flow, CORS permissions |
636| 2025-12-26 | Fixed: API response unwrapping - extension now correctly handles ApiResponse structure |
637| 2025-12-26 | Phase 1 ready for testing - all bugs resolved, decision graph: 295 nodes tracked |
638| 2025-12-27 | Phase 1 COMPLETE - all extension bugs fixed, ready for Chrome Web Store submission |
639| 2025-12-27 | Added: Loading screen, timezone fixes, Vite optimization, decision graph improvements |
640| 2025-12-27 | Decision graph: 332 nodes, 333 edges - orphan nodes resolved, documentation improved |