Skip to content

Commit 183a415

Browse files
committed
feat: limited TikTok export support
TikTok's export hides the creator behind every like/comment/watched video, so per-account attention analysis is impossible. Add a TikTok adapter and a dedicated limited report that honestly shows only what's recoverable: relationships (following/followers/mutual/blocked) and activity volume over time. Routes by export type; localized in all six languages. Also surface the live URL in the README.
1 parent 2f2367c commit 183a415

12 files changed

Lines changed: 352 additions & 2 deletions

File tree

README.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
11
# influence-sensor
22

3+
### ▶ Try it live: **https://endika.github.io/influence-sensor/**
4+
35
Find out **how captured your social feed is** — which accounts actually own your attention,
46
not just who you follow. Drop your Instagram data export and get a health score, an
5-
interactive graph, and the raw numbers behind it.
7+
interactive graph, and the raw numbers behind it. (A limited, relationships-only mode also
8+
supports TikTok exports — TikTok hides who you engage with.)
69

710
Everything runs in your browser. Your export is never uploaded or stored — a strict
811
Content-Security-Policy (`connect-src 'none'`) makes that enforceable, not just a promise.

src/adapters/tiktok.ts

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
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+
}

src/i18n/locales/ca.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,16 @@ export const ca: Record<string, string> = {
3030
'trend.rising': 'La teva atenció es concentra MÉS amb el temps — cada vegada més capturat.',
3131
'trend.falling': 'La teva atenció es reparteix més amb el temps — menys capturat.',
3232
'trend.flat': 'La teva concentració s’ha mantingut més o menys estable amb el temps.',
33+
'tk.notice':
34+
'L’export de TikTok amaga amb qui interactues — cada m’agrada, comentari i vídeo vist porta només un ID de vídeo anònim. Així que això mostra les teves relacions i quant uses TikTok, però NO qui et capta (sense graf, salut ni infecció).',
35+
'tk.hidden':
36+
'{watched} vídeos vistos, {likes} m’agrada i {comments} comentaris — cap d’ells porta el creador en l’export.',
37+
'tk.relTitle': 'Les teves relacions a TikTok',
38+
'tk.relCaption':
39+
'{following} seguits · {followers} seguidors · {mutual} mutus · {blocked} bloquejats.',
40+
'tk.activityTitle': 'Quant uses TikTok',
41+
'tk.activityCaption':
42+
'El teu volum d’activitat en el temps (m’agrada, comentaris i historial de visualització), tot i que TikTok amagui de qui era el contingut.',
3343
'drop.prompt':
3444
'Tria o deixa caure el teu export d’Instagram (.zip en format JSON). No surt mai del teu navegador.',
3545
'drop.helpSummary': 'Com descarregar l’export correcte',

src/i18n/locales/en.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,15 @@ export const en: Record<string, string> = {
3232
'trend.rising': 'Your attention is concentrating MORE over time — increasingly captured.',
3333
'trend.falling': 'Your attention is spreading out over time — less captured.',
3434
'trend.flat': 'Your concentration has stayed roughly stable over time.',
35+
'tk.notice':
36+
'TikTok’s export hides who you engage with — every like, comment and watched video carries only an anonymous video ID. So this shows your relationships and how much you use TikTok, but NOT who captures your attention (no graph, health or infection).',
37+
'tk.hidden':
38+
'{watched} videos watched, {likes} likes and {comments} comments — none of them name the creator in the export.',
39+
'tk.relTitle': 'Your TikTok relationships',
40+
'tk.relCaption': '{following} following · {followers} followers · {mutual} mutual · {blocked} blocked.',
41+
'tk.activityTitle': 'How much you use TikTok',
42+
'tk.activityCaption':
43+
'Your activity volume over time (likes, comments and watch history), even though TikTok hides whose content it was.',
3544
'drop.prompt':
3645
'Pick or drop your Instagram data export (.zip in JSON format). It never leaves your browser.',
3746
'drop.helpSummary': 'How to download the right export',

src/i18n/locales/es.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,15 @@ export const es: Record<string, string> = {
3131
'trend.rising': 'Tu atención se concentra MÁS con el tiempo — cada vez más capturado.',
3232
'trend.falling': 'Tu atención se reparte más con el tiempo — menos capturado.',
3333
'trend.flat': 'Tu concentración se ha mantenido más o menos estable con el tiempo.',
34+
'tk.notice':
35+
'El export de TikTok oculta a quién interactúas — cada me gusta, comentario y vídeo visto trae solo un ID de vídeo anónimo. Así que esto muestra tus relaciones y cuánto usas TikTok, pero NO quién te capta (sin grafo, salud ni infección).',
36+
'tk.hidden':
37+
'{watched} vídeos vistos, {likes} me gusta y {comments} comentarios — ninguno trae el creador en el export.',
38+
'tk.relTitle': 'Tus relaciones en TikTok',
39+
'tk.relCaption': '{following} seguidos · {followers} seguidores · {mutual} mutuos · {blocked} bloqueados.',
40+
'tk.activityTitle': 'Cuánto usas TikTok',
41+
'tk.activityCaption':
42+
'Tu volumen de actividad en el tiempo (me gusta, comentarios e historial de visualización), aunque TikTok oculte de quién era el contenido.',
3443
'drop.prompt':
3544
'Elige o suelta tu export de Instagram (.zip en formato JSON). Nunca sale de tu navegador.',
3645
'drop.helpSummary': 'Cómo descargar el export correcto',

src/i18n/locales/eu.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,16 @@ export const eu: Record<string, string> = {
3232
'Zure arreta GEHIAGO kontzentratzen da denborarekin — gero eta gehiago harrapatuta.',
3333
'trend.falling': 'Zure arreta gehiago banatzen da denborarekin — gutxiago harrapatuta.',
3434
'trend.flat': 'Zure kontzentrazioa gutxi gorabehera egonkor mantendu da denborarekin.',
35+
'tk.notice':
36+
'TikTok-eko esportazioak ezkutatzen du norekin elkarrekin aritzen zaren — atsegin dut bakoitzak, iruzkin bakoitzak eta ikusitako bideo bakoitzak bideo ID anonimo bat bakarrik ekartzen du. Beraz, honek zure harremanak eta TikTok zenbat erabiltzen duzun erakusten du, baina EZ nork harrapatzen zaituen (ez graforik, ez osasun-indizerik eta ez infekziorik).',
37+
'tk.hidden':
38+
'{watched} bideo ikusi, {likes} atsegin dut eta {comments} iruzkin — batek ere ez du sortzailea esportazioan jasotzen.',
39+
'tk.relTitle': 'Zure harremanak TikTok-en',
40+
'tk.relCaption':
41+
'{following} jarraitutako · {followers} jarraitzaile · {mutual} elkarrekiko · {blocked} blokeatutako.',
42+
'tk.activityTitle': 'TikTok zenbat erabiltzen duzun',
43+
'tk.activityCaption':
44+
'Zure jarduera-bolumena denboran zehar (atsegin dut, iruzkinak eta ikusteko historia), TikTok-ek edukiaren jabea ezkutatu arren.',
3545
'drop.prompt':
3646
'Aukeratu edo jaregin zure Instagram datu-esportazioa (.zip JSON formatuan). Ez da inoiz zure nabigatzailetik irteten.',
3747
'drop.helpSummary': 'Nola deskargatu esportazio egokia',

src/i18n/locales/gl.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,16 @@ export const gl: Record<string, string> = {
3131
'trend.rising': 'A túa atención concéntrase MÁS co tempo — cada vez máis capturado.',
3232
'trend.falling': 'A túa atención repártese máis co tempo — menos capturado.',
3333
'trend.flat': 'A túa concentración mantívose máis ou menos estable co tempo.',
34+
'tk.notice':
35+
'O export de TikTok oculta con quen interactúas — cada gústame, comentario e vídeo visto trae só un ID de vídeo anónimo. Así que isto mostra as túas relacións e canto usas TikTok, pero NON quen te capta (sen grafo, saúde nin infección).',
36+
'tk.hidden':
37+
'{watched} vídeos vistos, {likes} gústame e {comments} comentarios — ningún trae o creador no export.',
38+
'tk.relTitle': 'As túas relacións en TikTok',
39+
'tk.relCaption':
40+
'{following} seguidos · {followers} seguidores · {mutual} mutuos · {blocked} bloqueados.',
41+
'tk.activityTitle': 'Canto usas TikTok',
42+
'tk.activityCaption':
43+
'O teu volume de actividade ao longo do tempo (gústame, comentarios e historial de visualización), aínda que TikTok oculte de quen era o contido.',
3444
'drop.prompt':
3545
'Escolle ou solta o teu export de Instagram (.zip en formato JSON). Non sae nunca do teu navegador.',
3646
'drop.helpSummary': 'Como descargar o export correcto',

src/i18n/locales/va.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,16 @@ export const va: Record<string, string> = {
3030
'trend.rising': 'La teua atenció es concentra MÉS amb el temps — cada vegada més capturat.',
3131
'trend.falling': 'La teua atenció es repartix més amb el temps — menys capturat.',
3232
'trend.flat': 'La teua concentració s\'ha mantingut més o menys estable amb el temps.',
33+
'tk.notice':
34+
'L\'export de TikTok amaga amb qui interactues — cada m\'agrada, comentari i vídeo vist porta només un ID de vídeo anònim. Així que això mostra les teues relacions i quant uses TikTok, però NO qui et captura (sense graf, salut ni infecció).',
35+
'tk.hidden':
36+
'{watched} vídeos vistos, {likes} m\'agrada i {comments} comentaris — cap d\'ells porta el creador en l\'export.',
37+
'tk.relTitle': 'Les teues relacions a TikTok',
38+
'tk.relCaption':
39+
'{following} seguits · {followers} seguidors · {mutual} mutus · {blocked} bloquejats.',
40+
'tk.activityTitle': 'Quant uses TikTok',
41+
'tk.activityCaption':
42+
'El teu volum d\'activitat al llarg del temps (m\'agrada, comentaris i historial de visualització), tot i que TikTok amagui de qui era el contingut.',
3343
'drop.prompt':
3444
'Tria o arrossega el teu export d\'Instagram (.zip en format JSON). Mai ix del teu navegador.',
3545
'drop.helpSummary': 'Com descarregar l\'export correcte',

src/main.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,16 @@
11
import JSZip from 'jszip'
22
import { detectLocale, getLocale, LOCALES, setLocale, t } from './i18n'
33
import { detectAdapter } from './adapters/registry'
4+
import { detectTikTok, parseTikTok, type TikTokSummary } from './adapters/tiktok'
45
import { excludeSelf, ownerFromFilename } from './owner'
56
import { analyze, type Report } from './report-model'
67
import { renderReport } from './ui/view'
8+
import { renderTikTokReport } from './ui/tiktok-view'
79
import './style.css'
810

911
const app = document.querySelector<HTMLDivElement>('#app')!
1012
let lastReport: Report | null = null
13+
let lastTikTok: TikTokSummary | null = null
1114

1215
function langSelector(): HTMLElement {
1316
const sel = document.createElement('select')
@@ -57,6 +60,13 @@ async function handleFile(file: File, results: HTMLElement): Promise<void> {
5760
results.appendChild(status)
5861
try {
5962
const zip = await JSZip.loadAsync(file)
63+
if (detectTikTok(zip)) {
64+
lastTikTok = await parseTikTok(zip)
65+
lastReport = null
66+
status.remove()
67+
renderTikTokReport(results, lastTikTok)
68+
return
69+
}
6070
const adapter = detectAdapter(zip)
6171
if (!adapter) {
6272
status.textContent = t('status.unrecognized')
@@ -68,6 +78,7 @@ async function handleFile(file: File, results: HTMLElement): Promise<void> {
6878
return
6979
}
7080
lastReport = analyze(data)
81+
lastTikTok = null
7182
status.remove()
7283
renderReport(results, lastReport)
7384
} catch {
@@ -100,7 +111,8 @@ function render(): void {
100111
results.id = 'results'
101112
app.append(topbar, zone, results, footer())
102113
wireDropzone(zone, results)
103-
if (lastReport) renderReport(results, lastReport)
114+
if (lastTikTok) renderTikTokReport(results, lastTikTok)
115+
else if (lastReport) renderReport(results, lastReport)
104116
}
105117

106118
setLocale(detectLocale())

src/ui/tiktok-view.ts

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
import type { TikTokSummary } from '../adapters/tiktok'
2+
import { getLocale, t } from '../i18n'
3+
import { renderHourHistogram, renderVenn, renderWeekday, renderYearArea } from './insight-charts'
4+
5+
function section(title: string, caption: string): HTMLElement {
6+
const wrap = document.createElement('section')
7+
const h = document.createElement('h2')
8+
h.textContent = title
9+
const p = document.createElement('p')
10+
p.className = 'caption'
11+
p.textContent = caption
12+
wrap.append(h, p)
13+
return wrap
14+
}
15+
16+
function notice(text: string, kind: 'warn' | 'info'): HTMLElement {
17+
const p = document.createElement('p')
18+
p.className = `notice notice-${kind}`
19+
p.textContent = text
20+
return p
21+
}
22+
23+
const weekdayLabels = (): string[] =>
24+
Array.from({ length: 7 }, (_, d) =>
25+
new Date(Date.UTC(2023, 0, 1 + d)).toLocaleDateString(getLocale(), { weekday: 'short' }),
26+
)
27+
28+
export function renderTikTokReport(root: HTMLElement, s: TikTokSummary): void {
29+
root.innerHTML = ''
30+
const a = s.activity
31+
32+
if (a.firstTs) {
33+
const fmt = (ts: number) =>
34+
new Date(ts * 1000).toLocaleDateString(getLocale(), { year: 'numeric', month: 'short' })
35+
const years = ((a.lastTs - a.firstTs) / 86400 / 365).toFixed(1)
36+
const banner = document.createElement('p')
37+
banner.className = 'coverage'
38+
banner.textContent = t('coverage', { from: fmt(a.firstTs), to: fmt(a.lastTs), years })
39+
root.appendChild(banner)
40+
}
41+
42+
root.appendChild(notice(t('tk.notice'), 'warn'))
43+
root.appendChild(
44+
notice(t('tk.hidden', { watched: a.total ? s.hidden.watched : 0, likes: s.hidden.likes, comments: s.hidden.comments }), 'info'),
45+
)
46+
47+
const rel = section(
48+
t('tk.relTitle'),
49+
t('tk.relCaption', {
50+
following: s.following.size,
51+
followers: s.followers.size,
52+
mutual: s.mutual,
53+
blocked: s.blocked.size,
54+
}),
55+
)
56+
const grid = document.createElement('div')
57+
grid.className = 'statgrid'
58+
const stat = (n: number, label: string) => {
59+
const d = document.createElement('div')
60+
d.className = 'stat'
61+
d.innerHTML = `<span class="stat-n">${n}</span><span class="stat-l">${label}</span>`
62+
return d
63+
}
64+
grid.append(
65+
stat(s.following.size, t('stat.following')),
66+
stat(s.followers.size, t('stat.followers')),
67+
stat(s.mutual, t('stat.mutual')),
68+
)
69+
rel.appendChild(grid)
70+
renderVenn(rel, s.following.size, s.followers.size, s.mutual, {
71+
following: t('stat.following'),
72+
followers: t('stat.followers'),
73+
mutual: t('stat.mutual'),
74+
})
75+
root.appendChild(rel)
76+
77+
if (a.total > 0) {
78+
const act = section(t('tk.activityTitle'), t('tk.activityCaption'))
79+
if (a.byYear.length > 1) renderYearArea(act, a.byYear)
80+
root.appendChild(act)
81+
82+
const wd = section(t('weekday.title'), t('weekday.caption'))
83+
renderWeekday(wd, a.byWeekday, weekdayLabels())
84+
root.appendChild(wd)
85+
86+
const busiest = a.byHour.indexOf(Math.max(...a.byHour))
87+
const when = section(
88+
t('when.title'),
89+
t('when.caption', { hour: busiest < 0 ? 0 : busiest, days: Math.round((a.lastTs - a.firstTs) / 86400) }),
90+
)
91+
renderHourHistogram(when, a.byHour)
92+
root.appendChild(when)
93+
}
94+
}

0 commit comments

Comments
 (0)