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.
11
fork

Configure Feed

Select the types of activity you want to include in your feed.

Paginate accounts view and refresh bot suffix from Twitter

jack 8e9bba2c 8898a871

+130 -13
+26 -3
src/profile-mirror.ts
··· 14 14 '', 15 15 ); 16 16 const MIRROR_SUFFIX = '{bot}'; 17 + const LEGACY_MIRROR_SUFFIX = '{unofficial}'; 17 18 const FEDIVERSE_BRIDGE_HANDLE = 'ap.brid.gy'; 18 19 const MIN_BRIDGE_ACCOUNT_AGE_MS = 7 * 24 * 60 * 60 * 1000; 19 20 const BOT_SELF_LABEL_VALUE = 'bot'; ··· 227 228 228 229 const normalizeWhitespace = (value: string): string => value.replace(/\s+/g, ' ').trim(); 229 230 231 + const stripMirrorDisplaySuffixes = (value: string): string => { 232 + if (!value) { 233 + return value; 234 + } 235 + 236 + const suffixPattern = /\s*\{(?:bot|unofficial)\}\s*/gi; 237 + return normalizeWhitespace(value.replace(suffixPattern, ' ')); 238 + }; 239 + 230 240 const shouldStripTrackingParam = (rawName: string): boolean => { 231 241 const name = rawName.trim().toLowerCase(); 232 242 if (!name) { ··· 472 482 }; 473 483 474 484 export const buildMirroredDisplayName = (name: string | undefined, username: string): string => { 475 - const baseName = normalizeWhitespace(name || '') || `@${normalizeTwitterUsername(username)}`; 485 + const cleanedName = stripMirrorDisplaySuffixes(normalizeWhitespace(name || '')); 486 + const baseName = cleanedName || `@${normalizeTwitterUsername(username)}`; 476 487 const lowerSuffix = MIRROR_SUFFIX.toLowerCase(); 477 488 const merged = baseName.toLowerCase().endsWith(lowerSuffix) ? baseName : `${baseName} ${MIRROR_SUFFIX}`; 478 489 return truncateGraphemes(merged, 64); ··· 650 661 bskyIdentifier: string; 651 662 bskyPassword: string; 652 663 bskyServiceUrl?: string; 664 + twitterUsername?: string; 653 665 }): Promise<EnsureDisplayNameBotSuffixResult> => { 654 666 const { agent, credentials } = await loginBlueskyAgent(args); 655 667 const repo = agent.session?.did || credentials.did; ··· 679 691 } 680 692 681 693 const currentDisplayName = normalizeOptionalString(existingProfileRecord.displayName); 682 - const nextDisplayName = buildMirroredDisplayName(currentDisplayName, credentials.handle); 694 + const normalizedTwitterUsername = normalizeTwitterUsername(args.twitterUsername || ''); 695 + let sourceDisplayName = currentDisplayName; 696 + let sourceUsername = credentials.handle; 697 + 698 + if (normalizedTwitterUsername) { 699 + const twitterProfile = await fetchTwitterMirrorProfile(normalizedTwitterUsername); 700 + sourceDisplayName = normalizeOptionalString(twitterProfile.name); 701 + sourceUsername = twitterProfile.username; 702 + } 703 + 704 + const nextDisplayName = buildMirroredDisplayName(sourceDisplayName, sourceUsername); 683 705 const currentNormalized = normalizeWhitespace(currentDisplayName || ''); 684 - const updated = currentNormalized !== nextDisplayName; 706 + const legacySuffixPresent = currentNormalized.toLowerCase().includes(LEGACY_MIRROR_SUFFIX); 707 + const updated = currentNormalized !== nextDisplayName || legacySuffixPresent; 685 708 686 709 if (updated) { 687 710 const nextProfileRecord: Record<string, unknown> = {
+20
src/server.ts
··· 2506 2506 2507 2507 for (const mapping of targets) { 2508 2508 try { 2509 + const sourceTwitterUsername = resolveProfileSyncSourceUsername({ 2510 + twitterUsernames: mapping.twitterUsernames, 2511 + fallbackSource: mapping.profileSyncSourceUsername, 2512 + }); 2513 + if (!sourceTwitterUsername) { 2514 + failed += 1; 2515 + failedMappings.push({ 2516 + id: mapping.id, 2517 + bskyIdentifier: mapping.bskyIdentifier, 2518 + error: 'Mapping has no Twitter source usernames.', 2519 + }); 2520 + continue; 2521 + } 2522 + 2509 2523 const result = await ensureBlueskyDisplayNameBotSuffix({ 2510 2524 bskyIdentifier: mapping.bskyIdentifier, 2511 2525 bskyPassword: mapping.bskyPassword, 2512 2526 bskyServiceUrl: mapping.bskyServiceUrl, 2527 + twitterUsername: sourceTwitterUsername, 2513 2528 }); 2514 2529 2515 2530 if (result.updated) { 2516 2531 appended += 1; 2517 2532 } else { 2518 2533 alreadyAppended += 1; 2534 + } 2535 + 2536 + if (mapping.profileSyncSourceUsername !== sourceTwitterUsername) { 2537 + mapping.profileSyncSourceUsername = sourceTwitterUsername; 2538 + changed = true; 2519 2539 } 2520 2540 2521 2541 if (mapping.lastMirroredDisplayName !== result.displayName) {
+84 -10
web/src/App.tsx
··· 416 416 const ADD_ACCOUNT_STEP_COUNT = ADD_ACCOUNT_STEPS.length; 417 417 const ACCOUNT_SEARCH_MIN_SCORE = 22; 418 418 const ACCOUNT_ROWS_BATCH_SIZE = 40; 419 + const ACCOUNT_PAGE_SIZE_DEFAULT = 50; 419 420 const DEFAULT_BACKFILL_LIMIT = 15; 420 421 const FEDIVERSE_BRIDGE_MIN_AGE_MS = 7 * 24 * 60 * 60 * 1000; 421 422 const DEFAULT_USER_PERMISSIONS: UserPermissions = { ··· 852 853 }); 853 854 const [accountsViewMode, setAccountsViewMode] = useState<'grouped' | 'global'>('grouped'); 854 855 const [accountsSearchQuery, setAccountsSearchQuery] = useState(''); 856 + const [accountsPage, setAccountsPage] = useState(1); 857 + const [accountsPageSize, setAccountsPageSize] = useState(ACCOUNT_PAGE_SIZE_DEFAULT); 855 858 const [accountsBulkAction, setAccountsBulkAction] = useState<BulkAccountsAction>('sync_profiles'); 856 859 const [accountsBulkScope, setAccountsBulkScope] = useState<'all' | 'selected'>('all'); 857 860 const [selectedAccountMappingIds, setSelectedAccountMappingIds] = useState<string[]>([]); ··· 1788 1791 () => filteredGroupedMappings.reduce((total, group) => total + group.mappings.length, 0), 1789 1792 [filteredGroupedMappings], 1790 1793 ); 1794 + const paginatedMappings = useMemo(() => filteredGroupedMappings.flatMap((group) => group.mappings), [filteredGroupedMappings]); 1795 + const accountsPageCount = useMemo( 1796 + () => Math.max(1, Math.ceil(paginatedMappings.length / Math.max(1, accountsPageSize))), 1797 + [accountsPageSize, paginatedMappings.length], 1798 + ); 1799 + useEffect(() => { 1800 + setAccountsPage(1); 1801 + }, [accountsPageSize, accountsSearchQuery, accountsViewMode, accountsCreatorFilter]); 1802 + useEffect(() => { 1803 + if (accountsPage > accountsPageCount) { 1804 + setAccountsPage(accountsPageCount); 1805 + } 1806 + }, [accountsPage, accountsPageCount]); 1807 + const pagedMappingIdSet = useMemo(() => { 1808 + const safePageSize = Math.max(1, accountsPageSize); 1809 + const start = Math.max(0, (accountsPage - 1) * safePageSize); 1810 + const end = start + safePageSize; 1811 + return new Set(paginatedMappings.slice(start, end).map((mapping) => mapping.id)); 1812 + }, [accountsPage, accountsPageSize, paginatedMappings]); 1813 + const pagedGroupedMappings = useMemo( 1814 + () => 1815 + filteredGroupedMappings 1816 + .map((group) => ({ 1817 + ...group, 1818 + mappings: group.mappings.filter((mapping) => pagedMappingIdSet.has(mapping.id)), 1819 + })) 1820 + .filter((group) => group.mappings.length > 0), 1821 + [filteredGroupedMappings, pagedMappingIdSet], 1822 + ); 1823 + const currentAccountsPageStart = accountMatchesCount === 0 ? 0 : (accountsPage - 1) * Math.max(1, accountsPageSize) + 1; 1824 + const currentAccountsPageEnd = 1825 + accountMatchesCount === 0 ? 0 : Math.min(accountMatchesCount, accountsPage * Math.max(1, accountsPageSize)); 1791 1826 useEffect(() => { 1792 1827 setVisibleRowsByGroupKey((previous) => { 1793 - const validGroupKeys = new Set(filteredGroupedMappings.map((group) => group.key)); 1828 + const validGroupKeys = new Set(pagedGroupedMappings.map((group) => group.key)); 1794 1829 const next: Record<string, number> = {}; 1795 1830 1796 1831 for (const [groupKey, count] of Object.entries(previous)) { ··· 1799 1834 } 1800 1835 } 1801 1836 1802 - for (const group of filteredGroupedMappings) { 1837 + for (const group of pagedGroupedMappings) { 1803 1838 const defaultVisible = Math.min(group.mappings.length, ACCOUNT_ROWS_BATCH_SIZE); 1804 1839 const existing = next[group.key] || 0; 1805 1840 next[group.key] = Math.max(existing, defaultVisible); ··· 1807 1842 1808 1843 return next; 1809 1844 }); 1810 - }, [filteredGroupedMappings]); 1845 + }, [pagedGroupedMappings]); 1811 1846 const groupKeysForCollapse = useMemo(() => groupedMappings.map((group) => group.key), [groupedMappings]); 1812 1847 const allGroupsCollapsed = useMemo( 1813 1848 () => ··· 1882 1917 const selectedManageableMappingsCount = selectedManageableMappingsForView.length; 1883 1918 const filteredManageableMappingIds = useMemo(() => { 1884 1919 const seen = new Set<string>(); 1885 - for (const group of filteredGroupedMappings) { 1920 + for (const group of pagedGroupedMappings) { 1886 1921 for (const mapping of group.mappings) { 1887 1922 if (seen.has(mapping.id) || !canManageMapping(mapping)) { 1888 1923 continue; ··· 1891 1926 } 1892 1927 } 1893 1928 return [...seen]; 1894 - }, [canManageMapping, filteredGroupedMappings]); 1929 + }, [canManageMapping, pagedGroupedMappings]); 1895 1930 const allFilteredManageableSelected = 1896 1931 filteredManageableMappingIds.length > 0 && 1897 1932 filteredManageableMappingIds.every((mappingId) => selectedAccountMappingIdSet.has(mappingId)); ··· 2718 2753 } 2719 2754 2720 2755 const confirmed = window.confirm( 2721 - `Append {bot} to display names for ${candidates.length} account(s)? This only appends when missing.`, 2756 + `Refresh display names from Twitter and append {bot} for ${candidates.length} account(s)? Legacy {UNOFFICIAL} will be removed.`, 2722 2757 ); 2723 2758 if (!confirmed) { 2724 2759 return; ··· 4444 4479 onClick={() => toggleSelectAllFilteredManageable(!allFilteredManageableSelected)} 4445 4480 > 4446 4481 {allFilteredManageableSelected 4447 - ? `Unselect matched (${filteredManageableMappingIds.length})` 4448 - : `Select matched (${filteredManageableMappingIds.length})`} 4482 + ? `Unselect page (${filteredManageableMappingIds.length})` 4483 + : `Select page (${filteredManageableMappingIds.length})`} 4449 4484 </Button> 4450 4485 <Button 4451 4486 size="sm" ··· 4518 4553 )} 4519 4554 </div> 4520 4555 4521 - {filteredGroupedMappings.length === 0 ? ( 4556 + <div className="flex flex-wrap items-center justify-between gap-2 rounded-lg border border-border/70 bg-muted/20 px-3 py-2"> 4557 + <p className="text-xs text-muted-foreground"> 4558 + Showing {currentAccountsPageStart}-{currentAccountsPageEnd} of {accountMatchesCount} account 4559 + {accountMatchesCount === 1 ? '' : 's'} 4560 + </p> 4561 + <div className="flex items-center gap-2"> 4562 + <span className="text-xs text-muted-foreground">Per page</span> 4563 + <select 4564 + className={cn(selectClassName, 'h-8 w-24 px-2 py-1 text-xs')} 4565 + value={accountsPageSize} 4566 + onChange={(event) => setAccountsPageSize(Number(event.target.value) || ACCOUNT_PAGE_SIZE_DEFAULT)} 4567 + > 4568 + <option value={25}>25</option> 4569 + <option value={50}>50</option> 4570 + <option value={100}>100</option> 4571 + <option value={200}>200</option> 4572 + </select> 4573 + <Button 4574 + size="sm" 4575 + variant="outline" 4576 + disabled={accountsPage <= 1} 4577 + onClick={() => setAccountsPage((previous) => Math.max(1, previous - 1))} 4578 + > 4579 + <ChevronLeft className="h-4 w-4" /> 4580 + </Button> 4581 + <span className="w-24 text-center text-xs text-muted-foreground"> 4582 + Page {accountsPage}/{accountsPageCount} 4583 + </span> 4584 + <Button 4585 + size="sm" 4586 + variant="outline" 4587 + disabled={accountsPage >= accountsPageCount} 4588 + onClick={() => setAccountsPage((previous) => Math.min(accountsPageCount, previous + 1))} 4589 + > 4590 + <ChevronRight className="h-4 w-4" /> 4591 + </Button> 4592 + </div> 4593 + </div> 4594 + 4595 + {accountMatchesCount === 0 ? ( 4522 4596 <div className="rounded-lg border border-dashed border-border/70 p-6 text-center text-sm text-muted-foreground"> 4523 4597 {normalizedAccountsQuery ? 'No accounts matched this search.' : 'No mappings yet.'} 4524 4598 {canCreateMappings ? ( ··· 4532 4606 </div> 4533 4607 ) : ( 4534 4608 <div className="space-y-3"> 4535 - {filteredGroupedMappings.map((group, groupIndex) => { 4609 + {pagedGroupedMappings.map((group, groupIndex) => { 4536 4610 const canCollapseGroup = accountsViewMode === 'grouped'; 4537 4611 const collapsed = canCollapseGroup ? collapsedGroupKeys[group.key] === true : false; 4538 4612 const visibleRows = visibleRowsByGroupKey[group.key] || ACCOUNT_ROWS_BATCH_SIZE;