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