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└─────────────────────────────────────────────────────────────────┘ 142143144 ┌──────────────────┐ 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└─────────────────┘ └────────┬────────┘ └─────────────────┘ 239240 │ Returns: { importId: "abc123" } 241242 ┌─────────────────┐ 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 |