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