ATlast — you'll never need to find your favorites on another platform again. Find your favs in the ATmosphere.
atproto

massively simplify and improve file parsing

+14 -13
src/hooks/useFileUpload.ts
··· 1 - import { parseFile, PlatformParseError } from '../lib/platforms/parser'; 2 - import type { SocialUser } from '../lib/platforms/types'; 1 + import { parseDataFile } from '../lib/fileExtractor'; 3 2 import type { SearchResult } from '../types'; 4 3 5 4 export function useFileUpload( ··· 11 10 if (!file) return; 12 11 13 12 onStatusUpdate(`Processing ${file.name}...`); 14 - let users: SocialUser[] = []; 13 + let usernames: string[] = []; 15 14 16 15 try { 17 - // Use the new platform-based parser 18 - users = await parseFile(file, platform); 16 + usernames = await parseDataFile(file, platform); 19 17 20 - console.log(`Loaded ${users.length} users from ${platform} data`); 21 - onStatusUpdate(`Loaded ${users.length} users from ${platform} data`); 18 + console.log(`Loaded ${usernames.length} users from ${platform} data`); 19 + onStatusUpdate(`Loaded ${usernames.length} users from ${platform} data`); 22 20 } catch (error) { 23 21 console.error("Error processing file:", error); 24 22 25 - const errorMsg = error instanceof PlatformParseError 23 + const errorMsg = error instanceof Error 26 24 ? error.message 27 25 : "There was a problem processing the file. Please check that it's a valid data export."; 28 26 ··· 31 29 return; 32 30 } 33 31 34 - if (users.length === 0) { 32 + if (usernames.length === 0) { 35 33 const errorMsg = "No users found in the file."; 36 34 onStatusUpdate(errorMsg); 37 35 alert(errorMsg); 38 36 return; 39 37 } 40 38 41 - // Initialize search results 42 - const initialResults: SearchResult[] = users.map(user => ({ 43 - sourceUser: user, 39 + // Initialize search results - convert usernames to SearchResult format 40 + const initialResults: SearchResult[] = usernames.map(username => ({ 41 + sourceUser: { 42 + username: username, 43 + date: '' 44 + }, 44 45 atprotoMatches: [], 45 46 isSearching: false, 46 47 selectedMatches: new Set<string>(), 47 48 sourcePlatform: platform 48 49 })); 49 50 50 - onStatusUpdate(`Starting search for ${users.length} users...`); 51 + onStatusUpdate(`Starting search for ${usernames.length} users...`); 51 52 onSearchStart(initialResults, platform); 52 53 } 53 54
+123
src/lib/fileExtractor.ts
··· 1 + import JSZip from 'jszip'; 2 + import { ParseRule, getRulesForPlatform, FileFormat } from './platformDefinitions'; 3 + import { parseContent } from './parserLogic'; 4 + 5 + // Type for the final aggregated results 6 + export interface ExtractionResults { 7 + allExtracted: Record<string, string[]>; 8 + uniqueUsernames: string[]; 9 + } 10 + 11 + export class DataExtractor { 12 + private file: File | ArrayBuffer | Blob; 13 + 14 + constructor(file: File | ArrayBuffer | Blob) { 15 + this.file = file; 16 + } 17 + 18 + public async processZipArchive(zip: JSZip, rules: ParseRule[]): Promise<ExtractionResults> { 19 + /** Core logic for extracting usernames from a successfully loaded ZIP archive. */ 20 + const allExtracted: Record<string, string[]> = {}; 21 + const uniqueUsernames: Set<string> = new Set(); 22 + 23 + for (let i = 0; i < rules.length; i++) { 24 + const rule = rules[i]; 25 + const ruleId = `Rule_${i + 1}_${rule.zipPath}`; 26 + console.log(`Processing ZIP file path ${rule.zipPath} (Format: ${rule.format})`); 27 + 28 + // 1. Get file object from ZIP 29 + const fileInZip = zip.file(rule.zipPath); 30 + if (!fileInZip) { 31 + console.warn(`WARNING: File not found in ZIP: '${rule.zipPath}'. Skipping rule.`); 32 + continue; 33 + } 34 + 35 + try { 36 + // 2. Read content asynchronously 37 + const content = await fileInZip.async("string"); 38 + 39 + // 3. Apply appropriate parsing logic 40 + const extracted = parseContent(content, rule); 41 + 42 + // 4. Store results 43 + allExtracted[ruleId] = extracted; 44 + extracted.forEach(name => uniqueUsernames.add(name)); 45 + 46 + } catch (e) { 47 + console.error(`ERROR reading file ${rule.zipPath} from ZIP:`, e); 48 + } 49 + } 50 + 51 + return { 52 + allExtracted, 53 + uniqueUsernames: Array.from(uniqueUsernames).sort() 54 + }; 55 + } 56 + } 57 + 58 + /** 59 + * Public facing function handling both ZIP and single files. 60 + * @param file A File object (or ArrayBuffer/Blob) representing the uploaded data. 61 + * @param platform The platform name (e.g., 'instagram', 'tiktok'). 62 + * @returns A promise that resolves to an array of unique usernames (string[]). 63 + */ 64 + export async function parseDataFile(file: File | ArrayBuffer | Blob, platform: string): Promise<string[]> { 65 + const rules = getRulesForPlatform(platform); 66 + 67 + if (rules.length === 0) { 68 + console.error(`No parsing rules found for platform: ${platform}`); 69 + return []; 70 + } 71 + 72 + // 1. --- ATTEMPT ZIP LOAD --- 73 + try { 74 + console.log("Attempting to load file as ZIP archive..."); 75 + const zip = await JSZip.loadAsync(file); 76 + 77 + const extractor = new DataExtractor(file); 78 + const results = await extractor.processZipArchive(zip, rules); 79 + 80 + console.log(`Successfully extracted ${results.uniqueUsernames.length} usernames from ZIP archive.`); 81 + return results.uniqueUsernames; 82 + 83 + } catch (e) { 84 + // 2. --- ZIP LOAD FAILED, ATTEMPT SINGLE FILE --- 85 + console.warn("ZIP load failed. Attempting to parse file as a single data file..."); 86 + 87 + // We need a File object to get the name and content easily 88 + if (!(file instanceof File) && !(file instanceof Blob)) { 89 + console.error("Input failed ZIP check and lacks a name/content structure for single file parsing (must be File or Blob)."); 90 + return []; 91 + } 92 + 93 + const singleFile = file as File; 94 + 95 + // Find the rule that matches the uploaded file name 96 + // We check if the uploaded filename ends with the final part of a rule's zipPath (e.g., "following.html") 97 + const matchingRule = rules.find(rule => 98 + singleFile.name.toLowerCase().endsWith((rule.zipPath.split('/').pop() || '').toLowerCase()) 99 + ); 100 + 101 + if (!matchingRule) { 102 + console.error(`Could not match single file '${singleFile.name}' to any rule for platform ${platform}. Check rules in platformDefinitions.ts.`); 103 + return []; 104 + } 105 + 106 + console.log(`Matched single file '${singleFile.name}' to rule: ${matchingRule.zipPath}`); 107 + 108 + // 3. Process as single file content 109 + try { 110 + const content = await singleFile.text(); 111 + const extracted = parseContent(content, matchingRule); 112 + 113 + const uniqueUsernames = Array.from(new Set(extracted)).sort(); 114 + console.log(`Successfully extracted ${uniqueUsernames.length} unique usernames from single file.`); 115 + 116 + return uniqueUsernames; 117 + 118 + } catch (contentError) { 119 + console.error("Error reading content of single file:", contentError); 120 + return []; 121 + } 122 + } 123 + }
+108
src/lib/parserLogic.ts
··· 1 + import { ParseRule, FileFormat } from './platformDefinitions'; 2 + 3 + /** 4 + * Parses content using a regular expression. 5 + * @param content The string content (HTML or plain text) to search within. 6 + * @param regexPattern The regex string defining the capture group for the username. 7 + * @returns An array of extracted usernames. 8 + */ 9 + export function parseTextOrHtml(content: string, regexPattern: string): string[] { 10 + try { 11 + // 'g' for global matching, 's' for multiline (DOTALL equivalent) 12 + const pattern = new RegExp(regexPattern, 'gs'); 13 + 14 + // matchAll returns an iterator of matches; we spread it into an array. 15 + const matches = [...content.matchAll(pattern)]; 16 + 17 + // We map the results to the first captured group (match[1]), filtering out empty results. 18 + return matches.map(match => match[1]).filter(name => !!name); 19 + 20 + } catch (e) { 21 + console.error(`ERROR: Invalid regex pattern '${regexPattern}':`, e); 22 + return []; 23 + } 24 + } 25 + 26 + /** 27 + * Traverses a JSON object structure to extract usernames from a nested array of objects. 28 + * Assumes the common pattern: navigate to an array, and extract a key from each object in that array. 29 + * @param content The JSON content as a string. 30 + * @param pathKeys The array of keys defining the path, where the second to last key is the array key, and the last key is the target username field. 31 + * @returns An array of extracted usernames. 32 + */ 33 + export function parseJson(content: string, pathKeys: string[]): string[] { 34 + try { 35 + const data = JSON.parse(content); 36 + const usernames: string[] = []; 37 + 38 + if (pathKeys.length < 2) { 39 + console.error("JSON rule must have at least two path keys (list key and target key)."); 40 + return []; 41 + } 42 + 43 + // Determine the navigation path 44 + let currentData: any = data; 45 + const listContainerPath = pathKeys.slice(0, -2); 46 + const listKey = pathKeys[pathKeys.length - 2]; 47 + const targetKey = pathKeys[pathKeys.length - 1]; 48 + 49 + // 1. Traverse down to the object containing the target array 50 + for (const key of listContainerPath) { 51 + if (typeof currentData === 'object' && currentData !== null && key in currentData) { 52 + currentData = currentData[key]; 53 + } else { 54 + console.error(`ERROR: Could not traverse JSON path up to key: ${key}. Path: ${listContainerPath.join(' -> ')}`); 55 + return []; 56 + } 57 + } 58 + 59 + // 2. Check if the penultimate key holds the array 60 + if (typeof currentData === 'object' && currentData !== null && listKey in currentData) { 61 + const userList = currentData[listKey]; 62 + 63 + if (Array.isArray(userList)) { 64 + // 3. Iterate over the array and extract the final target key 65 + for (const item of userList) { 66 + if (typeof item === 'object' && item !== null && targetKey in item) { 67 + // Found the username 68 + usernames.push(String(item[targetKey])); 69 + } 70 + } 71 + } else { 72 + console.error(`ERROR: Expected an array at key '${listKey}' but found a different type.`); 73 + } 74 + } else { 75 + console.error(`ERROR: List key '${listKey}' not found at its expected position.`); 76 + } 77 + 78 + return usernames; 79 + 80 + } catch (e) { 81 + if (e instanceof SyntaxError) { 82 + console.error(`ERROR: Could not decode JSON content:`, e); 83 + } else { 84 + console.error(`An unexpected error occurred during JSON parsing:`, e); 85 + } 86 + return []; 87 + } 88 + } 89 + 90 + /** 91 + * Universal wrapper to apply the correct parsing method based on the rule's format. 92 + * @param content The file content as a string. 93 + * @param rule The ParseRule to apply. 94 + * @returns An array of extracted usernames. 95 + */ 96 + export function parseContent(content: string, rule: ParseRule): string[] { 97 + if (rule.format === 'HTML' || rule.format === 'TEXT') { 98 + if (typeof rule.rule === 'string') { 99 + return parseTextOrHtml(content, rule.rule); 100 + } 101 + } else if (rule.format === 'JSON') { 102 + if (Array.isArray(rule.rule)) { 103 + return parseJson(content, rule.rule); 104 + } 105 + } 106 + console.error(`ERROR: Unsupported format or invalid rule type for rule with path: ${rule.zipPath}`); 107 + return []; 108 + }
+53
src/lib/platformDefinitions.ts
··· 1 + // Use string literals for type safety on formats 2 + export type FileFormat = 'HTML' | 'TEXT' | 'JSON'; 3 + 4 + // Define the structure for a single parsing rule 5 + export interface ParseRule { 6 + zipPath: string; // File path *inside* the ZIP archive 7 + format: FileFormat; // Expected format of the file, e.g. 'HTML', 'TEXT', 'JSON' 8 + rule: string | string[]; // specific extraction rule (regex pattern string or JSON key path array) 9 + } 10 + 11 + /* 12 + PLATFORM DEFINITIONS 13 + This constant holds all the defined extraction rules, grouped by platform. 14 + */ 15 + 16 + export const PLATFORM_RULES: Record<string, ParseRule[]> = { 17 + 18 + "instagram": [ 19 + { 20 + zipPath: "connections/followers_and_following/following.html", 21 + format: "HTML", 22 + // Regex captures the username group 'beautyscicomm' from the URL: 23 + // https://www.instagram.com/_u/beautyscicomm 24 + // Note: The 'g' and 's' flags are handled in the extractor method. 25 + rule: '<a target="_blank" href="https://www.instagram.com/_u/([^"]+)"' 26 + }, 27 + { 28 + zipPath: "connections/followers_and_following/following.json", 29 + format: "JSON", 30 + rule: ["relationships_following", "title"] 31 + } 32 + ], 33 + 34 + "tiktok": [ 35 + { 36 + zipPath: "TikTok/Profile and Settings/Following.txt", 37 + format: "TEXT", 38 + // Regex captures the text after "Username: " on the same line 39 + rule: "Username:\s*([^\r\n]+)" 40 + }, 41 + { 42 + zipPath: "tiktok.json", 43 + format: "JSON", 44 + // JSON key path to traverse: ['Your Activity'] -> ['Following'] -> ['Following'] -> 'UserName' 45 + rule: ["Your Activity", "Following", "Following", "UserName"] 46 + } 47 + ], 48 + }; 49 + 50 + export function getRulesForPlatform(platformName: string): ParseRule[] { 51 + // Retrieves the list of parsing rules for a given platform. 52 + return PLATFORM_RULES[platformName.toLowerCase()] || []; 53 + }
-149
src/lib/platforms/instagram.ts
··· 1 - // src/lib/platforms/instagram.ts 2 - 3 - import type { PlatformConfig, PlatformParser, FileBundle, SocialUser } from './types'; 4 - import { PlatformParseError } from './types'; 5 - 6 - // HTML Parser for Instagram following.html 7 - const htmlParser: PlatformParser = { 8 - name: 'Instagram HTML', 9 - canParse: (bundle: FileBundle) => { 10 - for (const [_, file] of bundle.files) { 11 - if (file.type === 'html' && file.name.toLowerCase().includes('following')) { 12 - return file.content.includes('_a6-g') || file.content.includes('uiBoxWhite'); 13 - } 14 - } 15 - return false; 16 - }, 17 - parse: async (bundle: FileBundle) => { 18 - const users: SocialUser[] = []; 19 - 20 - // Find HTML file 21 - let htmlContent = ''; 22 - for (const [_, file] of bundle.files) { 23 - if (file.type === 'html' && file.name.toLowerCase().includes('following')) { 24 - htmlContent = file.content; 25 - break; 26 - } 27 - } 28 - 29 - if (!htmlContent) { 30 - throw new PlatformParseError('No Instagram following.html file found', 'instagram'); 31 - } 32 - 33 - // Parse the HTML 34 - const parser = new DOMParser(); 35 - const doc = parser.parseFromString(htmlContent, 'text/html'); 36 - 37 - // Instagram following data is in specific divs 38 - const userDivs = doc.querySelectorAll('div.pam._3-95._2ph-._a6-g.uiBoxWhite.noborder'); 39 - 40 - userDivs.forEach((div) => { 41 - const h2 = div.querySelector('h2._3-95._2pim._a6-h._a6-i'); 42 - const dateDiv = div.querySelector('div._a6-p > div > div:nth-child(2)'); 43 - 44 - if (h2) { 45 - const username = h2.textContent?.trim(); 46 - const date = dateDiv?.textContent?.trim() || ''; 47 - 48 - if (username) { 49 - users.push({ 50 - username: username, 51 - date: date 52 - }); 53 - } 54 - } 55 - }); 56 - 57 - if (users.length === 0) { 58 - throw new PlatformParseError( 59 - 'No following data found in Instagram HTML file', 60 - 'instagram' 61 - ); 62 - } 63 - 64 - return users; 65 - } 66 - }; 67 - 68 - // JSON Parser for Instagram JSON exports 69 - const jsonParser: PlatformParser = { 70 - name: 'Instagram JSON', 71 - canParse: (bundle: FileBundle) => { 72 - for (const [_, file] of bundle.files) { 73 - if (file.type === 'json') { 74 - try { 75 - const data = JSON.parse(file.content); 76 - return !!(data?.relationships_following || data?.following); 77 - } catch { 78 - return false; 79 - } 80 - } 81 - } 82 - return false; 83 - }, 84 - parse: async (bundle: FileBundle) => { 85 - const users: SocialUser[] = []; 86 - 87 - // Find and parse JSON file 88 - for (const [_, file] of bundle.files) { 89 - if (file.type === 'json') { 90 - try { 91 - const jsonData = JSON.parse(file.content); 92 - 93 - // Instagram JSON exports can have different structures 94 - let followingArray = jsonData?.relationships_following; 95 - 96 - if (!followingArray && jsonData?.following) { 97 - followingArray = jsonData.following; 98 - } 99 - 100 - if (!Array.isArray(followingArray)) { 101 - continue; 102 - } 103 - 104 - for (const entry of followingArray) { 105 - const username = entry.string_list_data?.[0]?.value || entry.username || entry.handle; 106 - const timestamp = entry.string_list_data?.[0]?.timestamp || entry.timestamp; 107 - 108 - if (username) { 109 - users.push({ 110 - username: username, 111 - date: timestamp ? new Date(timestamp * 1000).toISOString() : '' 112 - }); 113 - } 114 - } 115 - 116 - if (users.length > 0) { 117 - return users; 118 - } 119 - } catch (e) { 120 - continue; 121 - } 122 - } 123 - } 124 - 125 - throw new PlatformParseError( 126 - 'No valid Instagram JSON data found. Expected relationships_following or following array', 127 - 'instagram' 128 - ); 129 - } 130 - }; 131 - 132 - // Instagram Platform Configuration 133 - export const instagramPlatform: PlatformConfig = { 134 - id: 'instagram', 135 - name: 'Instagram', 136 - parsers: [htmlParser, jsonParser], // Try HTML first (most common) 137 - expectedFiles: ['following.html', 'connections.json', 'followers_and_following.json'], 138 - validate: (bundle: FileBundle) => { 139 - // Check if bundle contains Instagram-like files 140 - for (const [path, file] of bundle.files) { 141 - if (path.toLowerCase().includes('instagram') || 142 - path.toLowerCase().includes('connections') || 143 - (file.name.toLowerCase().includes('following') && file.type === 'html')) { 144 - return true; 145 - } 146 - } 147 - return false; 148 - } 149 - };
-120
src/lib/platforms/parser.ts
··· 1 - import JSZip from "jszip"; 2 - import type { FileBundle, SocialUser } from './types'; 3 - import { PlatformParseError } from './types'; 4 - import { getPlatform } from './registry'; 5 - 6 - // Convert a file into a FileBundle (extract ZIP if needed) 7 - async function createBundle(file: File): Promise<FileBundle> { 8 - const bundle: FileBundle = { 9 - files: new Map(), 10 - originalFileName: file.name 11 - }; 12 - 13 - if (file.name.endsWith('.zip')) { 14 - // Extract ZIP contents 15 - const zip = await JSZip.loadAsync(file); 16 - 17 - for (const [path, zipEntry] of Object.entries(zip.files)) { 18 - if (zipEntry.dir) continue; // Skip directories 19 - 20 - const content = await zipEntry.async('string'); 21 - const fileName = path.split('/').pop() || path; 22 - 23 - // Determine file type 24 - let type: 'text' | 'html' | 'json' = 'text'; 25 - if (fileName.endsWith('.html')) type = 'html'; 26 - else if (fileName.endsWith('.json')) type = 'json'; 27 - else if (fileName.endsWith('.txt')) type = 'text'; 28 - 29 - bundle.files.set(path, { 30 - name: fileName, 31 - content, 32 - type 33 - }); 34 - } 35 - } else { 36 - // Single file 37 - const content = await file.text(); 38 - let type: 'text' | 'html' | 'json' = 'text'; 39 - 40 - if (file.name.endsWith('.html')) type = 'html'; 41 - else if (file.name.endsWith('.json')) type = 'json'; 42 - else if (file.name.endsWith('.txt')) type = 'text'; 43 - 44 - bundle.files.set(file.name, { 45 - name: file.name, 46 - content, 47 - type 48 - }); 49 - } 50 - 51 - return bundle; 52 - } 53 - 54 - /** 55 - * Parse a file for a specific platform 56 - */ 57 - export async function parseFile(file: File, platformId: string): Promise<SocialUser[]> { 58 - // Get platform config 59 - const platform = getPlatform(platformId); 60 - if (!platform) { 61 - throw new PlatformParseError( 62 - `Platform '${platformId}' is not supported`, 63 - platformId 64 - ); 65 - } 66 - 67 - // Create file bundle 68 - const bundle = await createBundle(file); 69 - 70 - if (bundle.files.size === 0) { 71 - throw new PlatformParseError( 72 - 'No files found in upload', 73 - platformId 74 - ); 75 - } 76 - 77 - // Validate bundle contains expected files (optional check) 78 - if (!platform.validate(bundle)) { 79 - const expectedFiles = platform.expectedFiles.join(', '); 80 - throw new PlatformParseError( 81 - `File doesn't appear to be ${platform.name} data. Expected files like: ${expectedFiles}`, 82 - platformId 83 - ); 84 - } 85 - 86 - // Try each parser in order 87 - const errors: string[] = []; 88 - 89 - for (const parser of platform.parsers) { 90 - if (!parser.canParse(bundle)) { 91 - continue; // Skip parsers that can't handle this bundle 92 - } 93 - 94 - try { 95 - const users = await parser.parse(bundle); 96 - 97 - if (users.length === 0) { 98 - errors.push(`${parser.name}: No users found`); 99 - continue; 100 - } 101 - 102 - console.log(`Successfully parsed ${users.length} users using ${parser.name}`); 103 - return users; 104 - } catch (error) { 105 - const errorMsg = error instanceof Error ? error.message : 'Unknown error'; 106 - errors.push(`${parser.name}: ${errorMsg}`); 107 - console.warn(`${parser.name} failed:`, errorMsg); 108 - } 109 - } 110 - 111 - // All parsers failed 112 - throw new PlatformParseError( 113 - `Could not parse ${platform.name} data. Tried: ${errors.join('; ')}`, 114 - platformId 115 - ); 116 - } 117 - 118 - // Export for backwards compatibility 119 - export { PlatformParseError } from './types'; 120 - export type { SocialUser } from './types';
-26
src/lib/platforms/registry.ts
··· 1 - import type { PlatformConfig } from './types'; 2 - import { tiktokPlatform } from './tiktok'; 3 - import { instagramPlatform } from './instagram'; 4 - 5 - // Registry of all supported platforms 6 - const platformRegistry = new Map<string, PlatformConfig>(); 7 - 8 - // Register platforms 9 - platformRegistry.set('tiktok', tiktokPlatform); 10 - platformRegistry.set('instagram', instagramPlatform); 11 - 12 - // Future platforms can be added here: 13 - // platformRegistry.set('twitter', twitterPlatform); 14 - // platformRegistry.set('youtube', youtubePlatform); 15 - 16 - export function getPlatform(platformId: string): PlatformConfig | undefined { 17 - return platformRegistry.get(platformId); 18 - } 19 - 20 - export function getAllPlatforms(): PlatformConfig[] { 21 - return Array.from(platformRegistry.values()); 22 - } 23 - 24 - export function isPlatformSupported(platformId: string): boolean { 25 - return platformRegistry.has(platformId); 26 - }
-114
src/lib/platforms/tiktok.ts
··· 1 - import type { PlatformConfig, PlatformParser, FileBundle, SocialUser } from './types'; 2 - import { PlatformParseError } from './types'; 3 - 4 - // TXT Parser for TikTok Following.txt format 5 - const txtParser: PlatformParser = { 6 - name: 'TikTok TXT', 7 - canParse: (bundle: FileBundle) => { 8 - // Look for .txt files that might be TikTok format 9 - for (const [_, file] of bundle.files) { 10 - if (file.name.toLowerCase().includes('following') && file.type === 'text') { 11 - return file.content.includes('Username:'); 12 - } 13 - } 14 - return false; 15 - }, 16 - parse: async (bundle: FileBundle) => { 17 - const users: SocialUser[] = []; 18 - 19 - // Find the TikTok following.txt file 20 - let content = ''; 21 - for (const [_, file] of bundle.files) { 22 - if (file.name.toLowerCase().includes('following') && file.type === 'text') { 23 - content = file.content; 24 - break; 25 - } 26 - } 27 - 28 - if (!content) { 29 - throw new PlatformParseError('No TikTok following.txt file found', 'tiktok'); 30 - } 31 - 32 - const entries = content.split("\n\n").map((b) => b.trim()).filter(Boolean); 33 - 34 - for (const entry of entries) { 35 - const userMatch = entry.match(/Username:\s*(.+)/); 36 - if (userMatch) { 37 - users.push({ username: userMatch[1].trim(), date: "" }); 38 - } 39 - } 40 - 41 - return users; 42 - } 43 - }; 44 - 45 - // JSON Parser for TikTok JSON exports 46 - const jsonParser: PlatformParser = { 47 - name: 'TikTok JSON', 48 - canParse: (bundle: FileBundle) => { 49 - for (const [_, file] of bundle.files) { 50 - if (file.type === 'json') { 51 - try { 52 - const data = JSON.parse(file.content); 53 - return !!data?.["Your Activity"]?.["Following"]?.["Following"]; 54 - } catch { 55 - return false; 56 - } 57 - } 58 - } 59 - return false; 60 - }, 61 - parse: async (bundle: FileBundle) => { 62 - const users: SocialUser[] = []; 63 - 64 - // Find and parse JSON file 65 - for (const [_, file] of bundle.files) { 66 - if (file.type === 'json') { 67 - try { 68 - const jsonData = JSON.parse(file.content); 69 - const followingArray = jsonData?.["Your Activity"]?.["Following"]?.["Following"]; 70 - 71 - if (!followingArray || !Array.isArray(followingArray)) { 72 - continue; 73 - } 74 - 75 - for (const entry of followingArray) { 76 - users.push({ 77 - username: entry.UserName, 78 - date: entry.Date || "", 79 - }); 80 - } 81 - 82 - if (users.length > 0) { 83 - return users; 84 - } 85 - } catch (e) { 86 - continue; 87 - } 88 - } 89 - } 90 - 91 - throw new PlatformParseError( 92 - 'No valid TikTok JSON data found. Expected path: Your Activity > Following > Following', 93 - 'tiktok' 94 - ); 95 - } 96 - }; 97 - 98 - // TikTok Platform Configuration 99 - export const tiktokPlatform: PlatformConfig = { 100 - id: 'tiktok', 101 - name: 'TikTok', 102 - parsers: [txtParser, jsonParser], // Try TXT first (most common) 103 - expectedFiles: ['Following.txt', 'user_data.json'], 104 - validate: (bundle: FileBundle) => { 105 - // Check if bundle contains TikTok-like files 106 - for (const [path, file] of bundle.files) { 107 - if (path.toLowerCase().includes('tiktok') || 108 - (file.name.toLowerCase().includes('following') && file.type === 'text')) { 109 - return true; 110 - } 111 - } 112 - return false; 113 - } 114 - };
-30
src/lib/platforms/types.ts
··· 1 - export interface SocialUser { 2 - username: string; 3 - date: string; 4 - } 5 - 6 - export interface FileBundle { 7 - files: Map<string, { name: string; content: string; type: 'text' | 'html' | 'json' }>; 8 - originalFileName: string; 9 - } 10 - 11 - export interface PlatformParser { 12 - name: string; 13 - parse: (bundle: FileBundle) => Promise<SocialUser[]>; 14 - canParse: (bundle: FileBundle) => boolean; 15 - } 16 - 17 - export interface PlatformConfig { 18 - id: string; 19 - name: string; 20 - parsers: PlatformParser[]; 21 - expectedFiles: string[]; // File patterns to look for 22 - validate: (bundle: FileBundle) => boolean; 23 - } 24 - 25 - export class PlatformParseError extends Error { 26 - constructor(message: string, public platform: string) { 27 - super(message); 28 - this.name = 'PlatformParseError'; 29 - } 30 - }