|
| 1 | +import type JSZip from 'jszip' |
| 2 | + |
| 3 | +/** |
| 4 | + * TikTok's data export hides the creator behind every like / comment / watched |
| 5 | + * video (each entry is only an anonymous video link), so per-account attention |
| 6 | + * analysis is impossible. Only relationships and activity volume are recoverable. |
| 7 | + */ |
| 8 | +export interface TikTokSummary { |
| 9 | + following: Set<string> |
| 10 | + followers: Set<string> |
| 11 | + mutual: number |
| 12 | + blocked: Set<string> |
| 13 | + hidden: { watched: number; likes: number; comments: number; favorites: number } |
| 14 | + activity: { |
| 15 | + total: number |
| 16 | + byYear: Array<{ year: number; count: number }> |
| 17 | + byWeekday: number[] // index 0 = Sunday |
| 18 | + byHour: number[] |
| 19 | + firstTs: number |
| 20 | + lastTs: number |
| 21 | + } |
| 22 | +} |
| 23 | + |
| 24 | +const TIKTOK_FILE = 'user_data_tiktok.json' |
| 25 | + |
| 26 | +function findFile(zip: JSZip): JSZip.JSZipObject | null { |
| 27 | + for (const path of Object.keys(zip.files)) { |
| 28 | + if (path.endsWith(TIKTOK_FILE)) return zip.files[path] |
| 29 | + } |
| 30 | + return null |
| 31 | +} |
| 32 | + |
| 33 | +/** A TikTok export is a single user_data_tiktok.json. */ |
| 34 | +export function detectTikTok(zip: JSZip): boolean { |
| 35 | + return findFile(zip) !== null |
| 36 | +} |
| 37 | + |
| 38 | +function arr(obj: any, ...path: string[]): any[] { |
| 39 | + let cur = obj |
| 40 | + for (const p of path) cur = cur?.[p] |
| 41 | + return Array.isArray(cur) ? cur : [] |
| 42 | +} |
| 43 | + |
| 44 | +/** Parse "YYYY-MM-DD HH:MM:SS" (UTC) into unix seconds; 0 if unparseable. */ |
| 45 | +function parseDate(s: unknown): number { |
| 46 | + if (typeof s !== 'string') return 0 |
| 47 | + const t = Date.parse(s.replace(' ', 'T') + 'Z') |
| 48 | + return Number.isNaN(t) ? 0 : Math.floor(t / 1000) |
| 49 | +} |
| 50 | + |
| 51 | +function usernames(entries: any[]): Set<string> { |
| 52 | + const set = new Set<string>() |
| 53 | + for (const e of entries) if (e?.UserName) set.add(e.UserName) |
| 54 | + return set |
| 55 | +} |
| 56 | + |
| 57 | +export async function parseTikTok(zip: JSZip): Promise<TikTokSummary> { |
| 58 | + const file = findFile(zip) |
| 59 | + const json = file ? JSON.parse(await file.async('string')) : {} |
| 60 | + |
| 61 | + const following = usernames(arr(json, 'Profile And Settings', 'Following', 'Following')) |
| 62 | + const followers = usernames(arr(json, 'Profile And Settings', 'Follower', 'FansList')) |
| 63 | + const blocked = usernames(arr(json, 'Profile And Settings', 'Block List', 'BlockList')) |
| 64 | + let mutual = 0 |
| 65 | + for (const u of following) if (followers.has(u)) mutual++ |
| 66 | + |
| 67 | + const likes = arr(json, 'Likes and Favorites', 'Like List', 'ItemFavoriteList') |
| 68 | + const favorites = arr(json, 'Likes and Favorites', 'Favorite Videos', 'FavoriteVideoList') |
| 69 | + const comments = arr(json, 'Comment', 'Comments', 'CommentsList') |
| 70 | + const watched = arr(json, 'Your Activity', 'Watch History', 'VideoList') |
| 71 | + |
| 72 | + const byYear = new Map<number, number>() |
| 73 | + const byWeekday = new Array(7).fill(0) |
| 74 | + const byHour = new Array(24).fill(0) |
| 75 | + let first = 0 |
| 76 | + let last = 0 |
| 77 | + let total = 0 |
| 78 | + const ingest = (entries: any[], field: string) => { |
| 79 | + for (const e of entries) { |
| 80 | + const ts = parseDate(e?.[field]) |
| 81 | + if (!ts) continue |
| 82 | + total++ |
| 83 | + const d = new Date(ts * 1000) |
| 84 | + byYear.set(d.getUTCFullYear(), (byYear.get(d.getUTCFullYear()) ?? 0) + 1) |
| 85 | + byWeekday[d.getUTCDay()]++ |
| 86 | + byHour[d.getUTCHours()]++ |
| 87 | + if (!first || ts < first) first = ts |
| 88 | + if (ts > last) last = ts |
| 89 | + } |
| 90 | + } |
| 91 | + ingest(likes, 'date') |
| 92 | + ingest(favorites, 'Date') |
| 93 | + ingest(comments, 'date') |
| 94 | + ingest(watched, 'Date') |
| 95 | + |
| 96 | + return { |
| 97 | + following, |
| 98 | + followers, |
| 99 | + mutual, |
| 100 | + blocked, |
| 101 | + hidden: { |
| 102 | + watched: watched.length, |
| 103 | + likes: likes.length, |
| 104 | + comments: comments.length, |
| 105 | + favorites: favorites.length, |
| 106 | + }, |
| 107 | + activity: { |
| 108 | + total, |
| 109 | + byYear: [...byYear.entries()].map(([year, count]) => ({ year, count })).sort((a, b) => a.year - b.year), |
| 110 | + byWeekday, |
| 111 | + byHour, |
| 112 | + firstTs: first, |
| 113 | + lastTs: last, |
| 114 | + }, |
| 115 | + } |
| 116 | +} |
0 commit comments