tangled
alpha
login
or
join now
byarielm.fyi
/
atlast
16
fork
atom
ATlast — you'll never need to find your favorites on another platform again. Find your favs in the ATmosphere.
atproto
16
fork
atom
overview
issues
1
pulls
pipelines
clean up file parsing
byarielm.fyi
4 months ago
4e0efdf7
1b0e2972
+449
-11
6 changed files
expand all
collapse all
unified
split
src
hooks
useFileUpload.ts
lib
platforms
instagram.ts
parser.ts
registry.ts
tiktok.ts
types.ts
+10
-11
src/hooks/useFileUpload.ts
···
1
1
-
import { fileParser, FileParseError } from '../lib/fileParser';
2
2
-
import type { TikTokUser, SearchResult } from '../types';
1
1
+
import { parseFile, PlatformParseError } from '../lib/platforms/parser';
2
2
+
import type { SocialUser } from '../lib/platforms/types';
3
3
+
import type { SearchResult } from '../types';
3
4
4
5
export function useFileUpload(
5
6
onSearchStart: (results: SearchResult[], platform: string) => void,
···
10
11
if (!file) return;
11
12
12
13
onStatusUpdate(`Processing ${file.name}...`);
13
13
-
let users: TikTokUser[] = [];
14
14
+
let users: SocialUser[] = [];
14
15
15
16
try {
16
16
-
users = await fileParser.parseFile(file);
17
17
+
// Use the new platform-based parser
18
18
+
users = await parseFile(file, platform);
17
19
18
18
-
const fileType = file.name.endsWith('.zip') ? 'ZIP' :
19
19
-
file.name.endsWith('.json') ? 'JSON' :
20
20
-
file.name.endsWith('.html') ? 'HTML' : 'TXT';
21
21
-
console.log(`Loaded ${users.length} users from ${fileType} file`);
22
22
-
onStatusUpdate(`Loaded ${users.length} users from ${fileType} file`);
20
20
+
console.log(`Loaded ${users.length} users from ${platform} data`);
21
21
+
onStatusUpdate(`Loaded ${users.length} users from ${platform} data`);
23
22
} catch (error) {
24
23
console.error("Error processing file:", error);
25
24
26
26
-
const errorMsg = error instanceof FileParseError
25
25
+
const errorMsg = error instanceof PlatformParseError
27
26
? error.message
28
27
: "There was a problem processing the file. Please check that it's a valid data export.";
29
28
···
41
40
42
41
// Initialize search results
43
42
const initialResults: SearchResult[] = users.map(user => ({
44
44
-
tiktokUser: user,
43
43
+
tiktokUser: user, // TODO: Rename to sourceUser in types
45
44
atprotoMatches: [],
46
45
isSearching: false,
47
46
selectedMatches: new Set<string>(),
+149
src/lib/platforms/instagram.ts
···
1
1
+
// src/lib/platforms/instagram.ts
2
2
+
3
3
+
import type { PlatformConfig, PlatformParser, FileBundle, SocialUser } from './types';
4
4
+
import { PlatformParseError } from './types';
5
5
+
6
6
+
// HTML Parser for Instagram following.html
7
7
+
const htmlParser: PlatformParser = {
8
8
+
name: 'Instagram HTML',
9
9
+
canParse: (bundle: FileBundle) => {
10
10
+
for (const [_, file] of bundle.files) {
11
11
+
if (file.type === 'html' && file.name.toLowerCase().includes('following')) {
12
12
+
return file.content.includes('_a6-g') || file.content.includes('uiBoxWhite');
13
13
+
}
14
14
+
}
15
15
+
return false;
16
16
+
},
17
17
+
parse: async (bundle: FileBundle) => {
18
18
+
const users: SocialUser[] = [];
19
19
+
20
20
+
// Find HTML file
21
21
+
let htmlContent = '';
22
22
+
for (const [_, file] of bundle.files) {
23
23
+
if (file.type === 'html' && file.name.toLowerCase().includes('following')) {
24
24
+
htmlContent = file.content;
25
25
+
break;
26
26
+
}
27
27
+
}
28
28
+
29
29
+
if (!htmlContent) {
30
30
+
throw new PlatformParseError('No Instagram following.html file found', 'instagram');
31
31
+
}
32
32
+
33
33
+
// Parse the HTML
34
34
+
const parser = new DOMParser();
35
35
+
const doc = parser.parseFromString(htmlContent, 'text/html');
36
36
+
37
37
+
// Instagram following data is in specific divs
38
38
+
const userDivs = doc.querySelectorAll('div.pam._3-95._2ph-._a6-g.uiBoxWhite.noborder');
39
39
+
40
40
+
userDivs.forEach((div) => {
41
41
+
const h2 = div.querySelector('h2._3-95._2pim._a6-h._a6-i');
42
42
+
const dateDiv = div.querySelector('div._a6-p > div > div:nth-child(2)');
43
43
+
44
44
+
if (h2) {
45
45
+
const username = h2.textContent?.trim();
46
46
+
const date = dateDiv?.textContent?.trim() || '';
47
47
+
48
48
+
if (username) {
49
49
+
users.push({
50
50
+
username: username,
51
51
+
date: date
52
52
+
});
53
53
+
}
54
54
+
}
55
55
+
});
56
56
+
57
57
+
if (users.length === 0) {
58
58
+
throw new PlatformParseError(
59
59
+
'No following data found in Instagram HTML file',
60
60
+
'instagram'
61
61
+
);
62
62
+
}
63
63
+
64
64
+
return users;
65
65
+
}
66
66
+
};
67
67
+
68
68
+
// JSON Parser for Instagram JSON exports
69
69
+
const jsonParser: PlatformParser = {
70
70
+
name: 'Instagram JSON',
71
71
+
canParse: (bundle: FileBundle) => {
72
72
+
for (const [_, file] of bundle.files) {
73
73
+
if (file.type === 'json') {
74
74
+
try {
75
75
+
const data = JSON.parse(file.content);
76
76
+
return !!(data?.relationships_following || data?.following);
77
77
+
} catch {
78
78
+
return false;
79
79
+
}
80
80
+
}
81
81
+
}
82
82
+
return false;
83
83
+
},
84
84
+
parse: async (bundle: FileBundle) => {
85
85
+
const users: SocialUser[] = [];
86
86
+
87
87
+
// Find and parse JSON file
88
88
+
for (const [_, file] of bundle.files) {
89
89
+
if (file.type === 'json') {
90
90
+
try {
91
91
+
const jsonData = JSON.parse(file.content);
92
92
+
93
93
+
// Instagram JSON exports can have different structures
94
94
+
let followingArray = jsonData?.relationships_following;
95
95
+
96
96
+
if (!followingArray && jsonData?.following) {
97
97
+
followingArray = jsonData.following;
98
98
+
}
99
99
+
100
100
+
if (!Array.isArray(followingArray)) {
101
101
+
continue;
102
102
+
}
103
103
+
104
104
+
for (const entry of followingArray) {
105
105
+
const username = entry.string_list_data?.[0]?.value || entry.username || entry.handle;
106
106
+
const timestamp = entry.string_list_data?.[0]?.timestamp || entry.timestamp;
107
107
+
108
108
+
if (username) {
109
109
+
users.push({
110
110
+
username: username,
111
111
+
date: timestamp ? new Date(timestamp * 1000).toISOString() : ''
112
112
+
});
113
113
+
}
114
114
+
}
115
115
+
116
116
+
if (users.length > 0) {
117
117
+
return users;
118
118
+
}
119
119
+
} catch (e) {
120
120
+
continue;
121
121
+
}
122
122
+
}
123
123
+
}
124
124
+
125
125
+
throw new PlatformParseError(
126
126
+
'No valid Instagram JSON data found. Expected relationships_following or following array',
127
127
+
'instagram'
128
128
+
);
129
129
+
}
130
130
+
};
131
131
+
132
132
+
// Instagram Platform Configuration
133
133
+
export const instagramPlatform: PlatformConfig = {
134
134
+
id: 'instagram',
135
135
+
name: 'Instagram',
136
136
+
parsers: [htmlParser, jsonParser], // Try HTML first (most common)
137
137
+
expectedFiles: ['following.html', 'connections.json', 'followers_and_following.json'],
138
138
+
validate: (bundle: FileBundle) => {
139
139
+
// Check if bundle contains Instagram-like files
140
140
+
for (const [path, file] of bundle.files) {
141
141
+
if (path.toLowerCase().includes('instagram') ||
142
142
+
path.toLowerCase().includes('connections') ||
143
143
+
(file.name.toLowerCase().includes('following') && file.type === 'html')) {
144
144
+
return true;
145
145
+
}
146
146
+
}
147
147
+
return false;
148
148
+
}
149
149
+
};
+120
src/lib/platforms/parser.ts
···
1
1
+
import JSZip from "jszip";
2
2
+
import type { FileBundle, SocialUser } from './types';
3
3
+
import { PlatformParseError } from './types';
4
4
+
import { getPlatform } from './registry';
5
5
+
6
6
+
// Convert a file into a FileBundle (extract ZIP if needed)
7
7
+
async function createBundle(file: File): Promise<FileBundle> {
8
8
+
const bundle: FileBundle = {
9
9
+
files: new Map(),
10
10
+
originalFileName: file.name
11
11
+
};
12
12
+
13
13
+
if (file.name.endsWith('.zip')) {
14
14
+
// Extract ZIP contents
15
15
+
const zip = await JSZip.loadAsync(file);
16
16
+
17
17
+
for (const [path, zipEntry] of Object.entries(zip.files)) {
18
18
+
if (zipEntry.dir) continue; // Skip directories
19
19
+
20
20
+
const content = await zipEntry.async('string');
21
21
+
const fileName = path.split('/').pop() || path;
22
22
+
23
23
+
// Determine file type
24
24
+
let type: 'text' | 'html' | 'json' = 'text';
25
25
+
if (fileName.endsWith('.html')) type = 'html';
26
26
+
else if (fileName.endsWith('.json')) type = 'json';
27
27
+
else if (fileName.endsWith('.txt')) type = 'text';
28
28
+
29
29
+
bundle.files.set(path, {
30
30
+
name: fileName,
31
31
+
content,
32
32
+
type
33
33
+
});
34
34
+
}
35
35
+
} else {
36
36
+
// Single file
37
37
+
const content = await file.text();
38
38
+
let type: 'text' | 'html' | 'json' = 'text';
39
39
+
40
40
+
if (file.name.endsWith('.html')) type = 'html';
41
41
+
else if (file.name.endsWith('.json')) type = 'json';
42
42
+
else if (file.name.endsWith('.txt')) type = 'text';
43
43
+
44
44
+
bundle.files.set(file.name, {
45
45
+
name: file.name,
46
46
+
content,
47
47
+
type
48
48
+
});
49
49
+
}
50
50
+
51
51
+
return bundle;
52
52
+
}
53
53
+
54
54
+
/**
55
55
+
* Parse a file for a specific platform
56
56
+
*/
57
57
+
export async function parseFile(file: File, platformId: string): Promise<SocialUser[]> {
58
58
+
// Get platform config
59
59
+
const platform = getPlatform(platformId);
60
60
+
if (!platform) {
61
61
+
throw new PlatformParseError(
62
62
+
`Platform '${platformId}' is not supported`,
63
63
+
platformId
64
64
+
);
65
65
+
}
66
66
+
67
67
+
// Create file bundle
68
68
+
const bundle = await createBundle(file);
69
69
+
70
70
+
if (bundle.files.size === 0) {
71
71
+
throw new PlatformParseError(
72
72
+
'No files found in upload',
73
73
+
platformId
74
74
+
);
75
75
+
}
76
76
+
77
77
+
// Validate bundle contains expected files (optional check)
78
78
+
if (!platform.validate(bundle)) {
79
79
+
const expectedFiles = platform.expectedFiles.join(', ');
80
80
+
throw new PlatformParseError(
81
81
+
`File doesn't appear to be ${platform.name} data. Expected files like: ${expectedFiles}`,
82
82
+
platformId
83
83
+
);
84
84
+
}
85
85
+
86
86
+
// Try each parser in order
87
87
+
const errors: string[] = [];
88
88
+
89
89
+
for (const parser of platform.parsers) {
90
90
+
if (!parser.canParse(bundle)) {
91
91
+
continue; // Skip parsers that can't handle this bundle
92
92
+
}
93
93
+
94
94
+
try {
95
95
+
const users = await parser.parse(bundle);
96
96
+
97
97
+
if (users.length === 0) {
98
98
+
errors.push(`${parser.name}: No users found`);
99
99
+
continue;
100
100
+
}
101
101
+
102
102
+
console.log(`Successfully parsed ${users.length} users using ${parser.name}`);
103
103
+
return users;
104
104
+
} catch (error) {
105
105
+
const errorMsg = error instanceof Error ? error.message : 'Unknown error';
106
106
+
errors.push(`${parser.name}: ${errorMsg}`);
107
107
+
console.warn(`${parser.name} failed:`, errorMsg);
108
108
+
}
109
109
+
}
110
110
+
111
111
+
// All parsers failed
112
112
+
throw new PlatformParseError(
113
113
+
`Could not parse ${platform.name} data. Tried: ${errors.join('; ')}`,
114
114
+
platformId
115
115
+
);
116
116
+
}
117
117
+
118
118
+
// Export for backwards compatibility
119
119
+
export { PlatformParseError } from './types';
120
120
+
export type { SocialUser } from './types';
+26
src/lib/platforms/registry.ts
···
1
1
+
import type { PlatformConfig } from './types';
2
2
+
import { tiktokPlatform } from './tiktok';
3
3
+
import { instagramPlatform } from './instagram';
4
4
+
5
5
+
// Registry of all supported platforms
6
6
+
const platformRegistry = new Map<string, PlatformConfig>();
7
7
+
8
8
+
// Register platforms
9
9
+
platformRegistry.set('tiktok', tiktokPlatform);
10
10
+
platformRegistry.set('instagram', instagramPlatform);
11
11
+
12
12
+
// Future platforms can be added here:
13
13
+
// platformRegistry.set('twitter', twitterPlatform);
14
14
+
// platformRegistry.set('youtube', youtubePlatform);
15
15
+
16
16
+
export function getPlatform(platformId: string): PlatformConfig | undefined {
17
17
+
return platformRegistry.get(platformId);
18
18
+
}
19
19
+
20
20
+
export function getAllPlatforms(): PlatformConfig[] {
21
21
+
return Array.from(platformRegistry.values());
22
22
+
}
23
23
+
24
24
+
export function isPlatformSupported(platformId: string): boolean {
25
25
+
return platformRegistry.has(platformId);
26
26
+
}
+114
src/lib/platforms/tiktok.ts
···
1
1
+
import type { PlatformConfig, PlatformParser, FileBundle, SocialUser } from './types';
2
2
+
import { PlatformParseError } from './types';
3
3
+
4
4
+
// TXT Parser for TikTok Following.txt format
5
5
+
const txtParser: PlatformParser = {
6
6
+
name: 'TikTok TXT',
7
7
+
canParse: (bundle: FileBundle) => {
8
8
+
// Look for .txt files that might be TikTok format
9
9
+
for (const [_, file] of bundle.files) {
10
10
+
if (file.name.toLowerCase().includes('following') && file.type === 'text') {
11
11
+
return file.content.includes('Username:');
12
12
+
}
13
13
+
}
14
14
+
return false;
15
15
+
},
16
16
+
parse: async (bundle: FileBundle) => {
17
17
+
const users: SocialUser[] = [];
18
18
+
19
19
+
// Find the TikTok following.txt file
20
20
+
let content = '';
21
21
+
for (const [_, file] of bundle.files) {
22
22
+
if (file.name.toLowerCase().includes('following') && file.type === 'text') {
23
23
+
content = file.content;
24
24
+
break;
25
25
+
}
26
26
+
}
27
27
+
28
28
+
if (!content) {
29
29
+
throw new PlatformParseError('No TikTok following.txt file found', 'tiktok');
30
30
+
}
31
31
+
32
32
+
const entries = content.split("\n\n").map((b) => b.trim()).filter(Boolean);
33
33
+
34
34
+
for (const entry of entries) {
35
35
+
const userMatch = entry.match(/Username:\s*(.+)/);
36
36
+
if (userMatch) {
37
37
+
users.push({ username: userMatch[1].trim(), date: "" });
38
38
+
}
39
39
+
}
40
40
+
41
41
+
return users;
42
42
+
}
43
43
+
};
44
44
+
45
45
+
// JSON Parser for TikTok JSON exports
46
46
+
const jsonParser: PlatformParser = {
47
47
+
name: 'TikTok JSON',
48
48
+
canParse: (bundle: FileBundle) => {
49
49
+
for (const [_, file] of bundle.files) {
50
50
+
if (file.type === 'json') {
51
51
+
try {
52
52
+
const data = JSON.parse(file.content);
53
53
+
return !!data?.["Your Activity"]?.["Following"]?.["Following"];
54
54
+
} catch {
55
55
+
return false;
56
56
+
}
57
57
+
}
58
58
+
}
59
59
+
return false;
60
60
+
},
61
61
+
parse: async (bundle: FileBundle) => {
62
62
+
const users: SocialUser[] = [];
63
63
+
64
64
+
// Find and parse JSON file
65
65
+
for (const [_, file] of bundle.files) {
66
66
+
if (file.type === 'json') {
67
67
+
try {
68
68
+
const jsonData = JSON.parse(file.content);
69
69
+
const followingArray = jsonData?.["Your Activity"]?.["Following"]?.["Following"];
70
70
+
71
71
+
if (!followingArray || !Array.isArray(followingArray)) {
72
72
+
continue;
73
73
+
}
74
74
+
75
75
+
for (const entry of followingArray) {
76
76
+
users.push({
77
77
+
username: entry.UserName,
78
78
+
date: entry.Date || "",
79
79
+
});
80
80
+
}
81
81
+
82
82
+
if (users.length > 0) {
83
83
+
return users;
84
84
+
}
85
85
+
} catch (e) {
86
86
+
continue;
87
87
+
}
88
88
+
}
89
89
+
}
90
90
+
91
91
+
throw new PlatformParseError(
92
92
+
'No valid TikTok JSON data found. Expected path: Your Activity > Following > Following',
93
93
+
'tiktok'
94
94
+
);
95
95
+
}
96
96
+
};
97
97
+
98
98
+
// TikTok Platform Configuration
99
99
+
export const tiktokPlatform: PlatformConfig = {
100
100
+
id: 'tiktok',
101
101
+
name: 'TikTok',
102
102
+
parsers: [txtParser, jsonParser], // Try TXT first (most common)
103
103
+
expectedFiles: ['Following.txt', 'user_data.json'],
104
104
+
validate: (bundle: FileBundle) => {
105
105
+
// Check if bundle contains TikTok-like files
106
106
+
for (const [path, file] of bundle.files) {
107
107
+
if (path.toLowerCase().includes('tiktok') ||
108
108
+
(file.name.toLowerCase().includes('following') && file.type === 'text')) {
109
109
+
return true;
110
110
+
}
111
111
+
}
112
112
+
return false;
113
113
+
}
114
114
+
};
+30
src/lib/platforms/types.ts
···
1
1
+
export interface SocialUser {
2
2
+
username: string;
3
3
+
date: string;
4
4
+
}
5
5
+
6
6
+
export interface FileBundle {
7
7
+
files: Map<string, { name: string; content: string; type: 'text' | 'html' | 'json' }>;
8
8
+
originalFileName: string;
9
9
+
}
10
10
+
11
11
+
export interface PlatformParser {
12
12
+
name: string;
13
13
+
parse: (bundle: FileBundle) => Promise<SocialUser[]>;
14
14
+
canParse: (bundle: FileBundle) => boolean;
15
15
+
}
16
16
+
17
17
+
export interface PlatformConfig {
18
18
+
id: string;
19
19
+
name: string;
20
20
+
parsers: PlatformParser[];
21
21
+
expectedFiles: string[]; // File patterns to look for
22
22
+
validate: (bundle: FileBundle) => boolean;
23
23
+
}
24
24
+
25
25
+
export class PlatformParseError extends Error {
26
26
+
constructor(message: string, public platform: string) {
27
27
+
super(message);
28
28
+
this.name = 'PlatformParseError';
29
29
+
}
30
30
+
}