A simple tool which lets you scrape twitter accounts and crosspost them to bluesky accounts! Comes with a CLI and a webapp for managing profiles! Works with images/videos/link embeds/threads.

fix: restore UI and fix scheduling logic

- Fixed React SPA UI: resolved hook ordering, stale closures in intervals, and incorrect dark mode CSS selectors
- Fixed scheduling logic: replaced cron with a loop to support immediate 'Run Now' triggers and dynamic Twitter config updates
- Added 'Clear Cache' feature to both backend and frontend for manual re-syncing
- Ensured Twitter client is properly re-initialized when cookies are updated via the web UI
- Fixed type errors and missing imports in the server and index files

jack 3bb1e161 d84b5466

+189 -120
+110 -81
public/index.html
··· 7 7 <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet"> 8 8 <link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet"> 9 9 <style> 10 - body { background-color: #f8f9fa; font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; } 11 - .card { border: none; border-radius: 12px; box-shadow: 0 4px 6px rgba(0,0,0,0.05); } 12 - .navbar { background-color: #fff; box-shadow: 0 2px 4px rgba(0,0,0,0.03); } 10 + body { background-color: #f8f9fa; font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; transition: background-color 0.3s, color 0.3s; } 11 + .card { border: none; border-radius: 12px; box-shadow: 0 4px 6px rgba(0,0,0,0.05); transition: background-color 0.3s, box-shadow 0.3s; } 12 + .navbar { background-color: #fff; box-shadow: 0 2px 4px rgba(0,0,0,0.03); transition: background-color 0.3s; } 13 13 .btn-primary { background-color: #0085ff; border: none; border-radius: 8px; } 14 14 .btn-primary:hover { background-color: #0072db; } 15 15 .login-container { max-width: 400px; margin: 100px auto; } 16 - .owner-badge { background-color: #e9ecef; color: #495057; padding: 4px 8px; border-radius: 6px; font-size: 0.8rem; font-weight: 600; } 16 + .owner-badge { background-color: #e9ecef; color: #495057; padding: 4px 8px; border-radius: 6px; font-size: 0.8rem; font-weight: 600; transition: background-color 0.3s, color 0.3s; } 17 17 18 - .dark body { background-color: #1a1a2e; color: #e0e0e0; } 19 - .dark .card { background-color: #16213e; box-shadow: 0 4px 6px rgba(0,0,0,0.3); } 20 - .dark .navbar { background-color: #16213e; color: #e0e0e0; } 21 - .dark .form-control { background-color: #0f3460; border-color: #1a4a7a; color: #e0e0e0; } 22 - .dark .form-control:focus { background-color: #0f3460; border-color: #0085ff; color: #e0e0e0; } 23 - .dark .form-label { color: #b0b0b0; } 24 - .dark .text-muted { color: #888 !important; } 25 - .dark input::placeholder { color: #666; } 26 - .dark .table { color: #e0e0e0; } 27 - .dark .table td, .dark .table th { border-color: #2a3a5a; } 28 - .dark hr { border-color: #2a3a5a; } 29 - .dark .owner-badge { background-color: #0f3460; color: #a0a0a0; } 30 - .dark .btn-outline-secondary { border-color: #4a5a7a; color: #a0a0a0; } 31 - .dark .btn-outline-secondary:hover { background-color: #0f3460; border-color: #4a5a7a; color: #e0e0e0; } 32 - .dark .btn-link { color: #6ea8fe; } 18 + /* Dark mode styles - fixed selectors */ 19 + body.dark { background-color: #1a1a2e; color: #e0e0e0; } 20 + body.dark .card { background-color: #16213e; box-shadow: 0 4px 6px rgba(0,0,0,0.3); } 21 + body.dark .navbar { background-color: #16213e; color: #e0e0e0; } 22 + body.dark .form-control { background-color: #0f3460; border-color: #1a4a7a; color: #e0e0e0; } 23 + body.dark .form-control:focus { background-color: #0f3460; border-color: #0085ff; color: #e0e0e0; } 24 + body.dark .form-label { color: #b0b0b0; } 25 + body.dark .text-muted { color: #888 !important; } 26 + body.dark input::placeholder { color: #666; } 27 + body.dark .table { color: #e0e0e0; } 28 + body.dark .table td, body.dark .table th { border-color: #2a3a5a; } 29 + body.dark hr { border-color: #2a3a5a; } 30 + body.dark .owner-badge { background-color: #0f3460; color: #a0a0a0; } 31 + body.dark .btn-outline-secondary { border-color: #4a5a7a; color: #a0a0a0; } 32 + body.dark .btn-outline-secondary:hover { background-color: #0f3460; border-color: #4a5a7a; color: #e0e0e0; } 33 + body.dark .btn-link { color: #6ea8fe; } 33 34 34 35 .status-dot { width: 8px; height: 8px; border-radius: 50%; display: inline-block; margin-right: 8px; } 35 36 .status-active { background-color: #28a745; } ··· 45 46 <script src="https://unpkg.com/axios/dist/axios.min.js"></script> 46 47 47 48 <script type="text/babel"> 48 - const { useState, useEffect } = React; 49 + const { useState, useEffect, useCallback } = React; 49 50 50 51 function App() { 51 52 const [token, setToken] = useState(localStorage.getItem('token')); 52 53 const [darkMode, setDarkMode] = useState(() => localStorage.getItem('darkMode') === 'true'); 53 - const [view, setView] = useState('login'); 54 + const [view, setView] = useState(localStorage.getItem('token') ? 'dashboard' : 'login'); 54 55 const [mappings, setMappings] = useState([]); 55 56 const [twitterConfig, setTwitterConfig] = useState({ authToken: '', ct0: '' }); 56 57 const [isAdmin, setIsAdmin] = useState(false); ··· 59 60 const [error, setError] = useState(''); 60 61 const [countdown, setCountdown] = useState(''); 61 62 62 - useEffect(() => { 63 - document.body.classList.toggle('dark', darkMode); 64 - localStorage.setItem('darkMode', darkMode); 65 - }, [darkMode]); 66 - 67 - useEffect(() => { 68 - if (!token) { 69 - setView('login'); 70 - return; 71 - } 72 - fetchData(); 73 - setView('dashboard'); 74 - }, [token]); 75 - 76 - useEffect(() => { 77 - if (view !== 'dashboard' || !token) return; 78 - 79 - fetchStatus(); 80 - const timer = setInterval(updateCountdown, 1000); 81 - const statusTimer = setInterval(fetchStatus, 30000); 82 - 83 - return () => { 84 - clearInterval(timer); 85 - clearInterval(statusTimer); 86 - }; 87 - }, [view, token]); 88 - 89 - const updateCountdown = () => { 90 - if (!status.nextCheckTime) return; 91 - const diff = status.nextCheckTime - Date.now(); 92 - if (diff <= 0) { 93 - setCountdown('Soon...'); 94 - return; 95 - } 96 - const mins = Math.floor(diff / 60000); 97 - const secs = Math.floor((diff % 60000) / 1000); 98 - setCountdown(`${mins}m ${secs}s`); 99 - }; 63 + const handleLogout = useCallback(() => { 64 + localStorage.removeItem('token'); 65 + setToken(null); 66 + setView('login'); 67 + setIsAdmin(false); 68 + }, []); 100 69 101 - const fetchStatus = async () => { 70 + const fetchStatus = useCallback(async () => { 102 71 if (!token) return; 103 72 try { 104 73 const res = await axios.get('/api/status', { 105 74 headers: { Authorization: `Bearer ${token}` } 106 75 }); 107 76 setStatus(res.data); 108 - updateCountdown(); 109 77 } catch (err) { 110 78 console.error('Failed to fetch status'); 79 + if (err.response?.status === 401) handleLogout(); 111 80 } 112 - }; 81 + }, [token, handleLogout]); 113 82 114 - const fetchData = async () => { 83 + const fetchData = useCallback(async () => { 84 + if (!token) return; 115 85 try { 116 86 const headers = { Authorization: `Bearer ${token}` }; 117 87 const [meRes, mapRes] = await Promise.all([ ··· 126 96 } 127 97 fetchStatus(); 128 98 } catch (err) { 99 + console.error('Failed to fetch data', err); 129 100 if (err.response?.status === 401) handleLogout(); 130 101 } 131 - }; 102 + }, [token, fetchStatus, handleLogout]); 103 + 104 + useEffect(() => { 105 + document.body.classList.toggle('dark', darkMode); 106 + localStorage.setItem('darkMode', darkMode); 107 + }, [darkMode]); 108 + 109 + useEffect(() => { 110 + if (token) { 111 + fetchData(); 112 + setView('dashboard'); 113 + } else { 114 + setView('login'); 115 + } 116 + }, [token, fetchData]); 117 + 118 + // Separate effect for intervals to handle status updates correctly 119 + useEffect(() => { 120 + if (view !== 'dashboard' || !token) return; 121 + 122 + const statusTimer = setInterval(fetchStatus, 30000); 123 + return () => clearInterval(statusTimer); 124 + }, [view, token, fetchStatus]); 125 + 126 + // Effect for countdown timer - depends on status.nextCheckTime 127 + useEffect(() => { 128 + if (!status.nextCheckTime) return; 129 + 130 + const updateCountdown = () => { 131 + const diff = status.nextCheckTime - Date.now(); 132 + if (diff <= 0) { 133 + setCountdown('Soon...'); 134 + return; 135 + } 136 + const mins = Math.floor(diff / 60000); 137 + const secs = Math.floor((diff % 60000) / 1000); 138 + setCountdown(`${mins}m ${secs}s`); 139 + }; 140 + 141 + updateCountdown(); 142 + const timer = setInterval(updateCountdown, 1000); 143 + return () => clearInterval(timer); 144 + }, [status.nextCheckTime]); 132 145 133 146 const handleLogin = async (e) => { 134 147 e.preventDefault(); ··· 159 172 } 160 173 }; 161 174 162 - const handleLogout = () => { 163 - localStorage.removeItem('token'); 164 - setToken(null); 165 - setView('login'); 166 - }; 167 - 168 175 const addMapping = async (e) => { 169 176 e.preventDefault(); 170 177 setLoading(true); ··· 204 211 headers: { Authorization: `Bearer ${token}` } 205 212 }); 206 213 alert('Check triggered!'); 207 - setTimeout(fetchStatus, 2000); 214 + setTimeout(fetchStatus, 1000); 208 215 } catch (err) { 209 216 alert('Failed to trigger check'); 210 217 } ··· 217 224 headers: { Authorization: `Bearer ${token}` } 218 225 }); 219 226 alert(`Backfill queued for @${twitterUsername}`); 220 - setTimeout(fetchStatus, 2000); 227 + setTimeout(fetchStatus, 1000); 221 228 } catch (err) { 222 229 alert(err.response?.data?.error || 'Failed to queue backfill'); 223 230 } 224 231 }; 225 232 233 + const clearCache = async (id, twitterUsername) => { 234 + if (!confirm(`Clear processed tweets cache for @${twitterUsername}? This will make the next run re-process recent tweets (potentially causing duplicates if they were already posted).`)) return; 235 + try { 236 + await axios.delete(`/api/mappings/${id}/cache`, { 237 + headers: { Authorization: `Bearer ${token}` } 238 + }); 239 + alert(`Cache cleared for @${twitterUsername}`); 240 + } catch (err) { 241 + alert('Failed to clear cache'); 242 + } 243 + }; 244 + 226 245 const updateTwitter = async (e) => { 227 246 e.preventDefault(); 228 247 const formData = new FormData(e.target); ··· 238 257 } 239 258 }; 240 259 241 - if (!token) { 260 + if (view === 'login' || view === 'register' || !token) { 242 261 return ( 243 - <div className="container login-container"> 262 + <div className={`container login-container ${darkMode ? 'dark' : ''}`}> 244 263 <div className="card p-4"> 245 264 <h2 className="text-center mb-4">{view === 'login' ? 'Login' : 'Register'}</h2> 246 265 {error && <div className="alert alert-danger">{error}</div>} ··· 257 276 {view === 'login' ? 'Login' : 'Register'} 258 277 </button> 259 278 <div className="text-center"> 260 - <a href="#" onClick={() => setView(view === 'login' ? 'register' : 'login')}> 279 + <a href="#" className="btn btn-link" onClick={() => setView(view === 'login' ? 'register' : 'login')}> 261 280 {view === 'login' ? 'Need an account? Register' : 'Have an account? Login'} 262 281 </a> 263 282 </div> 264 283 </form> 284 + <div className="text-center mt-3"> 285 + <button className="btn btn-outline-secondary btn-sm" onClick={() => setDarkMode(!darkMode)}> 286 + {darkMode ? 'Light Mode' : 'Dark Mode'} 287 + </button> 288 + </div> 265 289 </div> 266 290 </div> 267 291 ); ··· 270 294 const isBackfillQueued = (id) => status.pendingBackfills?.includes(id); 271 295 272 296 return ( 273 - <div> 297 + <div className={darkMode ? 'dark' : ''}> 274 298 <nav className="navbar navbar-expand-lg mb-4"> 275 299 <div className="container"> 276 300 <span className="navbar-brand d-flex align-items-center"> ··· 279 303 </span> 280 304 <div className="d-flex align-items-center gap-3"> 281 305 <span className="text-muted small"> 282 - Next: <span className="text-dark dark text-light">{countdown}</span> 306 + Next: <span className={darkMode ? 'text-light' : 'text-dark'}>{countdown}</span> 283 307 </span> 284 308 <button className="btn btn-outline-secondary btn-sm" onClick={runNow}> 285 309 Run Now ··· 371 395 </td> 372 396 <td className="text-end"> 373 397 {isAdmin && ( 374 - <button onClick={() => runBackfill(m.id, m.twitterUsername)} className="btn btn-link btn-sm text-warning p-0 me-2" title="Backfill"> 375 - <span className="material-icons">history</span> 376 - </button> 398 + <> 399 + <button onClick={() => runBackfill(m.id, m.twitterUsername)} className="btn btn-link btn-sm text-warning p-0 me-2" title="Backfill"> 400 + <span className="material-icons">history</span> 401 + </button> 402 + <button onClick={() => clearCache(m.id, m.twitterUsername)} className="btn btn-link btn-sm text-secondary p-0 me-2" title="Clear Cache"> 403 + <span className="material-icons">delete_sweep</span> 404 + </button> 405 + </> 377 406 )} 378 407 <button onClick={() => deleteMapping(m.id)} className="btn btn-link btn-sm text-danger p-0" title="Delete"> 379 408 <span className="material-icons">delete</span> ··· 396 425 root.render(<App />); 397 426 </script> 398 427 </body> 399 - </html> 428 + </html>
+56 -39
src/index.ts
··· 10 10 import { Command } from 'commander'; 11 11 import * as francModule from 'franc-min'; 12 12 import iso6391 from 'iso-639-1'; 13 - import cron from 'node-cron'; 14 13 import { getConfig } from './config-manager.js'; 15 14 16 15 // ESM __dirname equivalent ··· 177 176 } 178 177 179 178 let twitter: CustomTwitterClient; 179 + let currentTwitterCookies = { authToken: '', ct0: '' }; 180 + 181 + function getTwitterClient() { 182 + const config = getConfig(); 183 + if (!config.twitter.authToken || !config.twitter.ct0) return null; 184 + 185 + // Re-initialize if config changed or not yet initialized 186 + if (!twitter || 187 + currentTwitterCookies.authToken !== config.twitter.authToken || 188 + currentTwitterCookies.ct0 !== config.twitter.ct0) { 189 + twitter = new CustomTwitterClient({ 190 + cookies: { 191 + authToken: config.twitter.authToken, 192 + ct0: config.twitter.ct0, 193 + }, 194 + }); 195 + currentTwitterCookies = { 196 + authToken: config.twitter.authToken, 197 + ct0: config.twitter.ct0 198 + }; 199 + } 200 + return twitter; 201 + } 180 202 181 203 // ============================================================================ 182 204 // Helper Functions ··· 259 281 } 260 282 261 283 async function safeSearch(query: string, limit: number): Promise<TwitterSearchResult> { 284 + const client = getTwitterClient(); 285 + if (!client) return { success: false, error: 'Twitter client not configured' }; 286 + 262 287 try { 263 - const result = (await twitter.search(query, limit)) as TwitterSearchResult; 288 + const result = (await client.search(query, limit)) as TwitterSearchResult; 264 289 if (!result.success && result.error) { 265 290 const errorStr = result.error.toString(); 266 291 if (errorStr.includes('GraphQL') || errorStr.includes('404')) { ··· 277 302 ) { 278 303 await refreshQueryIds(); 279 304 console.log('Retrying search...'); 280 - return (await twitter.search(query, limit)) as TwitterSearchResult; 305 + return (await client.search(query, limit)) as TwitterSearchResult; 281 306 } 282 307 return { success: false, error }; 283 308 } ··· 568 593 } 569 594 } 570 595 571 - import { startServer, updateLastCheckTime, getPendingBackfills, clearBackfill } from './server.js'; 572 - 573 - // ... (previous imports) 596 + import { startServer, updateLastCheckTime, getPendingBackfills, clearBackfill, getNextCheckTime } from './server.js'; 574 597 575 598 async function main(): Promise<void> { 576 599 const program = new Command(); ··· 597 620 } 598 621 } 599 622 600 - // Allow starting even if twitter credentials are not set (can be set via web UI now) 601 - const twitterConfigured = config.twitter.authToken && config.twitter.ct0; 602 - 603 - if (twitterConfigured) { 604 - twitter = new CustomTwitterClient({ 605 - cookies: { 606 - authToken: config.twitter.authToken, 607 - ct0: config.twitter.ct0, 608 - }, 609 - }); 610 - } else { 611 - console.warn('⚠️ Twitter credentials not set. Use the web UI or CLI to configure them.'); 612 - } 613 - 614 623 if (options.importHistory) { 615 624 if (!options.username) { 616 625 console.error('Please specify a username with --username <username>'); 626 + process.exit(1); 627 + } 628 + const client = getTwitterClient(); 629 + if (!client) { 630 + console.error('Twitter credentials not set. Cannot import history.'); 617 631 process.exit(1); 618 632 } 619 633 await importHistory(options.username, options.limit, options.dryRun); 620 634 process.exit(0); 621 635 } 622 636 623 - if (twitter) { 624 - await checkAndPost(options.dryRun); 625 - } 626 - 627 637 if (options.dryRun) { 628 638 console.log('Dry run complete. Exiting.'); 629 639 process.exit(0); 630 640 } 631 641 632 - console.log(`Scheduling check every ${config.checkIntervalMinutes} minutes.`); 633 - cron.schedule(`*/${config.checkIntervalMinutes} * * * *`, () => { 634 - const pendingBackfills = getPendingBackfills(); 635 - const forceBackfill = pendingBackfills.length > 0; 636 - if (twitter || forceBackfill) { 637 - const currentConfig = getConfig(); 638 - if (!twitter && currentConfig.twitter.authToken && currentConfig.twitter.ct0) { 639 - twitter = new CustomTwitterClient({ 640 - cookies: { 641 - authToken: currentConfig.twitter.authToken, 642 - ct0: currentConfig.twitter.ct0, 643 - }, 644 - }); 642 + console.log(`Scheduler started. Base interval: ${config.checkIntervalMinutes} minutes.`); 643 + 644 + // Main loop to handle both scheduled runs and immediate triggers 645 + while (true) { 646 + const now = Date.now(); 647 + const nextTime = getNextCheckTime(); 648 + 649 + if (now >= nextTime) { 650 + const client = getTwitterClient(); 651 + const pendingBackfills = getPendingBackfills(); 652 + const forceBackfill = pendingBackfills.length > 0; 653 + 654 + if (client || forceBackfill) { 655 + try { 656 + await checkAndPost(options.dryRun, forceBackfill); 657 + } catch (err) { 658 + console.error('Error during scheduled check:', err); 659 + } 645 660 } 646 - checkAndPost(options.dryRun, forceBackfill); 647 661 } 648 - }); 662 + 663 + // Sleep for 10 seconds before checking again 664 + await new Promise(resolve => setTimeout(resolve, 10000)); 665 + } 649 666 } 650 667 651 668 main();
+23
src/server.ts
··· 1 + import fs from 'node:fs'; 1 2 import path from 'node:path'; 2 3 import { fileURLToPath } from 'node:url'; 3 4 import bcrypt from 'bcryptjs'; ··· 117 118 res.json({ success: true }); 118 119 }); 119 120 121 + app.delete('/api/mappings/:id/cache', authenticateToken, requireAdmin, (req, res) => { 122 + const { id } = req.params; 123 + const config = getConfig(); 124 + const mapping = config.mappings.find((m) => m.id === id); 125 + if (!mapping) { 126 + res.status(404).json({ error: 'Mapping not found' }); 127 + return; 128 + } 129 + 130 + const cachePath = path.join(__dirname, '../processed', `${mapping.twitterUsername.toLowerCase()}.json`); 131 + if (fs.existsSync(cachePath)) { 132 + fs.unlinkSync(cachePath); 133 + res.json({ success: true, message: 'Cache cleared' }); 134 + } else { 135 + res.json({ success: true, message: 'No cache found' }); 136 + } 137 + }); 138 + 120 139 // --- Twitter Config Routes (Admin Only) --- 121 140 122 141 app.get('/api/twitter-config', authenticateToken, requireAdmin, (_req, res) => { ··· 189 208 190 209 export function getPendingBackfills(): string[] { 191 210 return [...pendingBackfills]; 211 + } 212 + 213 + export function getNextCheckTime(): number { 214 + return nextCheckTime; 192 215 } 193 216 194 217 export function clearBackfill(id: string) {