Skip to content

Commit 50fa5f2

Browse files
committed
feat: insights-parser tests + EMPTY_DATA + README replace
- parser.ts: surface EMPTY_DATA when the zip validates as an Instagram export but parseable records are all empty. The UI now tells the user "this looks like an IG export but the format may have changed" instead of rendering a blank dashboard. Applied to both parseInstagramZip and parseFileFull. - parser.test.ts: the "empty followers_and_following directory" case flipped — it now asserts EMPTY_DATA rather than accepting a zero-result success. - locales/{en,ko}.json: new EMPTY_DATA error copy, framed as a call to action ("request a fresh export, open an issue on GitHub"). - insights-parser.test.ts (new): regression guards for the fragile HTML parsers — likedPosts / savedPosts / profileSearches / wordSearches / loginActivity / chatList — with both KO and EN label fixtures so a class-name or locale change in Instagram's export is caught before hitting users. Fixtures intentionally ship the extra wrapper noise IG emits. - README.md: replace the create-next-app template with an actual description of what followprint does, how to get the IG export, how to run tests, character classification table, and the Instagram-format-change playbook.
1 parent bc51d22 commit 50fa5f2

6 files changed

Lines changed: 304 additions & 29 deletions

File tree

README.md

Lines changed: 65 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,36 +1,80 @@
1-
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
1+
# followprint
22

3-
## Getting Started
3+
> Instagram 데이터 export ZIP 한 개를 끌어다 놓으면 팔로우 관계와 활동 패턴을
4+
> 그 자리에서 분석한다. **모든 처리는 브라우저 안에서 끝난다 — 서버 없음, 업로드 없음, 로그인 없음.**
45
5-
First, run the development server:
6+
## 무엇을 보여주는가
7+
8+
| 영역 | 내용 |
9+
| --- | --- |
10+
| 관계 분석 | 맞팔(`mutual`) / 내가만 따르는(`nonMutual`) / 팬만(`fansOnly`) / 보류 / 최근 언팔 / 친한 친구 / 차단 / 제한 |
11+
| 캐릭터 카드 | 6개 캐릭터 타입 (Influencer / Butterfly / Observer / Selective / Explorer / Minimalist) + 4개 점수 (Social / Loyalty / Curiosity / Selectivity) + 활동 시간대 + 월간 팔로우 속도 |
12+
| 인사이트 | 좋아요 많이 누른 계정 Top 20, 저장 게시물 Top 20, 프로필 검색 / 단어 검색 기록, 24시간 로그인 분포, 채팅 상대 |
13+
14+
## 데이터를 어떻게 받는가
15+
16+
1. Instagram 앱 → **설정 → 내 정보 및 권한 → 정보 다운로드**
17+
2. 형식: **JSON** (HTML도 호환)
18+
3. 데이터 종류: 모두 또는 `followers_and_following + activity`
19+
4. 받은 ZIP 파일을 followprint 페이지에 끌어다 놓는다
20+
21+
## 개인정보
22+
23+
- ZIP 안의 모든 파일은 **`JSZip` 으로 브라우저에서 직접 풀고 파싱한다**
24+
- 네트워크 요청은 폰트와 정적 자산 외에 **0건**
25+
- HTML 파싱 단계는 모두 `DOMPurify` 의 명시적 화이트리스트 (a, div, span, p, td, tr, table, ...) 를 통과한 뒤에만 DOMParser에 도달한다
26+
- 새로고침하면 데이터는 메모리에서 사라진다
27+
28+
## 기술 스택
29+
30+
- **Next.js 16** (App Router, `output: "export"` — 정적 사이트)
31+
- **React 19** + TypeScript strict
32+
- **Tailwind v4**
33+
- **JSZip** + **DOMPurify** + **vitest** + **jsdom**
34+
- **i18n**: 한국어 / 영어 토글, Instagram export 의 KO/EN 날짜 포맷 모두 파싱
35+
36+
## 개발
637

738
```bash
8-
npm run dev
9-
# or
10-
yarn dev
11-
# or
12-
pnpm dev
13-
# or
14-
bun dev
39+
npm install
40+
npm run dev # 개발 서버
41+
npm run build # 정적 사이트 빌드 (out/ 에 떨어짐)
42+
npm test # vitest run
43+
npm run lint # eslint
1544
```
1645

17-
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
46+
## 테스트
47+
48+
`src/lib/__tests__/` 안에 vitest 케이스가 있다:
1849

19-
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
50+
- `parser.test.ts` — JSON / HTML 양 포맷 + mutual / nonMutual / fansOnly 계산 + INVALID_ZIP / UNSUPPORTED_FORMAT / malformed entries + 7종 분류 (pending / unfollowed / closeFriends / blocked / restricted)
51+
- `parse-utils.test.ts` — KO / EN 날짜 (오전·오후·12시 경계) + DOMPurify XSS 회귀 (script / onclick stripping)
52+
- `character.test.ts` — 6개 캐릭터 타입 분류 + 점수 0~100 범위 + highlight 매칭 + 빈 입력 / 동률 케이스
53+
- `insights-parser.test.ts` — likedPosts / savedPosts / profileSearches / wordSearches / loginActivity / chatList 회귀 가드
2054

21-
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
55+
CI (`.github/workflows/ci.yml`) 에서 push / PR 마다 자동 실행한다.
2256

23-
## Learn More
57+
## 캐릭터 분류 기준
2458

25-
To learn more about Next.js, take a look at the following resources:
59+
| 타입 | 조건 |
60+
| --- | --- |
61+
| **Influencer** | followers / following 비율 > 3 AND followers > 500 |
62+
| **Selective** | following < 200 AND mutual / following > 0.6 |
63+
| **Explorer** | pending / (pending + following) > 0.1 |
64+
| **Butterfly** | following > 300 AND mutual / following > 0.5 (또는 default with mutualRate > 0.5) |
65+
| **Observer** | following > 300 AND mutual / following < 0.3 (또는 default) |
66+
| **Minimalist** | following < 100 AND followers < 100 |
2667

27-
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
28-
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
68+
`src/lib/character.ts` 에 정의되어 있다.
2969

30-
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
70+
## Instagram 포맷 변경 대응
3171

32-
## Deploy on Vercel
72+
Instagram 은 가끔 export 디렉토리 구조와 HTML 클래스명을 바꾼다. 회귀가 발생하면
73+
`src/lib/__tests__/parser.test.ts``insights-parser.test.ts` 가 먼저 깨지고,
74+
`parser.ts``validateInstagramZip` 가 새로운 경로 패턴을 받아들이지 못하면
75+
사용자에게 `INVALID_ZIP` 또는 `EMPTY_DATA` 가 노출된다. 두 함수 중 하나가
76+
fail 하면 IG export 형식 변경을 의심해야 한다.
3377

34-
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
78+
## License
3579

36-
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
80+
MIT
Lines changed: 205 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,205 @@
1+
// Regression guards for the insights HTML parsers. The Instagram export
2+
// format is not stable — class names and label text change every few months —
3+
// and these parsers are the most fragile surface in the project. Any test
4+
// that goes red here is a strong signal that IG changed their layout.
5+
//
6+
// The fixtures below are minimal extracts of real exports, simplified to the
7+
// shape that each parser actually walks. They intentionally include the
8+
// extra wrapper divs and class noise that IG ships, so that selector changes
9+
// (e.g. dropping `_2piu`) are caught.
10+
11+
import { describe, it, expect } from "vitest";
12+
import JSZip from "jszip";
13+
import { parseInsights } from "@/lib/insights-parser";
14+
15+
async function buildZip(files: Record<string, string>): Promise<JSZip> {
16+
const zip = new JSZip();
17+
for (const [path, content] of Object.entries(files)) {
18+
zip.file(path, content);
19+
}
20+
// Round-trip through generateAsync so that the resulting JSZip behaves the
21+
// same as one loaded from disk (file metadata, not just in-memory shortcut).
22+
const blob = await zip.generateAsync({ type: "blob" });
23+
return JSZip.loadAsync(blob);
24+
}
25+
26+
describe("parseInsights — likedPosts (KO label)", () => {
27+
it("extracts usernames from `사용자 이름` rows", async () => {
28+
const html = `
29+
<html><body>
30+
<div>
31+
<table>
32+
<tr><td>사용자 이름</td><td class="_2piu _a6_r">alice</td></tr>
33+
</table>
34+
</div>
35+
<div>
36+
<table>
37+
<tr><td>사용자 이름</td><td class="_2piu _a6_r">bob</td></tr>
38+
</table>
39+
</div>
40+
</body></html>
41+
`;
42+
const zip = await buildZip({
43+
"your_instagram_activity/likes/liked_posts.html": html,
44+
});
45+
const insights = await parseInsights(zip);
46+
const names = insights.topLikedAccounts.map((r) => r.name).sort();
47+
expect(names).toEqual(["alice", "bob"]);
48+
});
49+
});
50+
51+
describe("parseInsights — likedPosts (EN label)", () => {
52+
it("extracts usernames from `Username` rows", async () => {
53+
const html = `
54+
<html><body>
55+
<table>
56+
<tr><td>Username</td><td class="_2piu _a6_r">carol</td></tr>
57+
<tr><td>Username</td><td class="_2piu _a6_r">dave</td></tr>
58+
</table>
59+
</body></html>
60+
`;
61+
const zip = await buildZip({
62+
"your_instagram_activity/likes/liked_posts.html": html,
63+
});
64+
const insights = await parseInsights(zip);
65+
const names = insights.topLikedAccounts.map((r) => r.name).sort();
66+
expect(names).toEqual(["carol", "dave"]);
67+
});
68+
});
69+
70+
describe("parseInsights — savedPosts (h2 usernames)", () => {
71+
it("collects single-token h2 entries", async () => {
72+
const html = `
73+
<html><body>
74+
<h2>spaceship_one</h2>
75+
<h2>not a username</h2>
76+
<h2>cometchaser</h2>
77+
<h2>this_is_too_long_to_be_a_real_instagram_handle_xxxxxxxx</h2>
78+
</body></html>
79+
`;
80+
const zip = await buildZip({
81+
"your_instagram_activity/saved/saved_posts.html": html,
82+
});
83+
const insights = await parseInsights(zip);
84+
const names = insights.topSavedAccounts.map((r) => r.name).sort();
85+
// "not a username" rejected (whitespace), 50+ char string rejected.
86+
expect(names).toEqual(["cometchaser", "spaceship_one"]);
87+
});
88+
});
89+
90+
describe("parseInsights — profileSearches", () => {
91+
it("returns h2 names with extracted timestamps", async () => {
92+
const html = `
93+
<html><body>
94+
<div>
95+
<h2>searched_user_1</h2>
96+
<div><div>3월 16, 2026 6:41 오후</div></div>
97+
</div>
98+
<div>
99+
<h2>searched_user_2</h2>
100+
<div><div>4월 1, 2026 9:00 오전</div></div>
101+
</div>
102+
</body></html>
103+
`;
104+
const zip = await buildZip({
105+
"your_instagram_activity/recent_searches/profile_searches.html": html,
106+
});
107+
const insights = await parseInsights(zip);
108+
expect(insights.profileSearches).toHaveLength(2);
109+
expect(insights.profileSearches[0].name).toBe("searched_user_1");
110+
expect(insights.profileSearches[0].timestamp).toBeGreaterThan(0);
111+
});
112+
});
113+
114+
describe("parseInsights — wordSearches", () => {
115+
it("extracts query text from 검색 / Search rows", async () => {
116+
const html = `
117+
<html><body>
118+
<table>
119+
<tbody>
120+
<tr><td>검색<div><div>코딩</div></div></td><td class="_2piu">3월 16, 2026 6:41 오후</td></tr>
121+
</tbody>
122+
</table>
123+
<table>
124+
<tbody>
125+
<tr><td>Search<div><div>music</div></div></td><td class="_2piu">4월 1, 2026 9:00 오전</td></tr>
126+
</tbody>
127+
</table>
128+
</body></html>
129+
`;
130+
const zip = await buildZip({
131+
"your_instagram_activity/recent_searches/word_or_phrase_searches.html": html,
132+
});
133+
const insights = await parseInsights(zip);
134+
const queries = insights.wordSearches.map((r) => r.name).sort();
135+
expect(queries).toEqual(["music", "코딩"]);
136+
});
137+
});
138+
139+
describe("parseInsights — loginActivity", () => {
140+
it("counts ISO timestamps in h2 elements per hour", async () => {
141+
const html = `
142+
<html><body>
143+
<h2>2026-04-01T09:23:00Z</h2>
144+
<h2>2026-04-01T09:45:00Z</h2>
145+
<h2>2026-04-01T18:01:00Z</h2>
146+
</body></html>
147+
`;
148+
const zip = await buildZip({
149+
"security_and_login_information/login_activity.html": html,
150+
});
151+
const insights = await parseInsights(zip);
152+
expect(insights.loginHours[9]).toBe(2);
153+
expect(insights.loginHours[18]).toBe(1);
154+
expect(insights.loginHours.reduce((a, b) => a + b, 0)).toBe(3);
155+
});
156+
157+
it("counts KO 오전/오후 cells in 12-hour clock", async () => {
158+
const html = `
159+
<html><body>
160+
<table><tbody>
161+
<tr><td class="_2piu _a6_r">3월 16, 2026 6:41 오후</td></tr>
162+
<tr><td class="_2piu _a6_r">3월 16, 2026 6:50 오후</td></tr>
163+
<tr><td class="_2piu _a6_r">3월 16, 2026 9:00 오전</td></tr>
164+
</tbody></table>
165+
</body></html>
166+
`;
167+
const zip = await buildZip({
168+
"security_and_login_information/login_activity.html": html,
169+
});
170+
const insights = await parseInsights(zip);
171+
expect(insights.loginHours[18]).toBe(2);
172+
expect(insights.loginHours[9]).toBe(1);
173+
});
174+
});
175+
176+
describe("parseInsights — chats", () => {
177+
it("extracts chat partner names from h2 a", async () => {
178+
const html = `
179+
<html><body>
180+
<h2><a href="messages/inbox/alice">alice</a></h2>
181+
<h2><a href="messages/inbox/bob">bob</a></h2>
182+
</body></html>
183+
`;
184+
const zip = await buildZip({
185+
"your_instagram_activity/messages/chats.html": html,
186+
});
187+
const insights = await parseInsights(zip);
188+
expect(insights.chatNames.sort()).toEqual(["alice", "bob"]);
189+
});
190+
});
191+
192+
describe("parseInsights — empty / missing files", () => {
193+
it("returns zeros when none of the source files exist", async () => {
194+
const zip = await buildZip({
195+
"followers_and_following/followers_1.html": "<html></html>",
196+
});
197+
const insights = await parseInsights(zip);
198+
expect(insights.topLikedAccounts).toEqual([]);
199+
expect(insights.topSavedAccounts).toEqual([]);
200+
expect(insights.profileSearches).toEqual([]);
201+
expect(insights.wordSearches).toEqual([]);
202+
expect(insights.chatNames).toEqual([]);
203+
expect(insights.loginHours).toEqual(new Array(24).fill(0));
204+
});
205+
});

src/lib/__tests__/parser.test.ts

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -126,17 +126,16 @@ describe("parseInstagramZip", () => {
126126
await expect(parseInstagramZip(file)).rejects.toThrow("INVALID_ZIP");
127127
});
128128

129-
it("handles empty followers_and_following directory", async () => {
129+
it("rejects an Instagram-shaped zip with no actual records as EMPTY_DATA", async () => {
130+
// Validation passes (path contains "followers") but no parseable data
131+
// exists. The parser surfaces this as EMPTY_DATA so the UI can tell the
132+
// user "this looks like an IG export but the format may have changed",
133+
// which is more actionable than rendering an empty dashboard.
130134
const zip = new JSZip();
131-
// Directory marker exists but no actual data files inside
132135
zip.file("followers_and_following/readme.txt", "empty export");
133136

134137
const file = await zipToFile(zip);
135-
const result = await parseInstagramZip(file);
136-
137-
expect(result.followers).toHaveLength(0);
138-
expect(result.following).toHaveLength(0);
139-
expect(result.mutual).toHaveLength(0);
138+
await expect(parseInstagramZip(file)).rejects.toThrow("EMPTY_DATA");
140139
});
141140

142141
it("parses pending, unfollowed, closeFriends, blocked, restricted", async () => {

src/lib/parser.ts

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -151,14 +151,30 @@ function validateInstagramZip(zip: JSZip): void {
151151
if (!isInstagram) throw new Error("INVALID_ZIP");
152152
}
153153

154+
function isAnalysisEmpty(a: AnalysisResult): boolean {
155+
return (
156+
a.followers.length === 0 &&
157+
a.following.length === 0 &&
158+
a.pendingRequests.length === 0 &&
159+
a.recentlyUnfollowed.length === 0 &&
160+
a.closeFriends.length === 0 &&
161+
a.blockedAccounts.length === 0 &&
162+
a.restrictedAccounts.length === 0
163+
);
164+
}
165+
154166
// ── Main entry ──
155167

156168
export async function parseInstagramZip(
157169
file: File
158170
): Promise<AnalysisResult> {
159171
const zip = await JSZip.loadAsync(file);
160172
validateInstagramZip(zip);
161-
return analyzeZip(zip);
173+
const analysis = await analyzeZip(zip);
174+
if (isAnalysisEmpty(analysis)) {
175+
throw new Error("EMPTY_DATA");
176+
}
177+
return analysis;
162178
}
163179

164180
export async function parseFileFull(file: File): Promise<FullData> {
@@ -172,5 +188,14 @@ export async function parseFileFull(file: File): Promise<FullData> {
172188
parseInsights(zip),
173189
]);
174190

191+
// The validate step only checks that *some* path mentions followers /
192+
// following — that catches "you uploaded the wrong zip" — but it can still
193+
// produce 0 records if Instagram changed their export schema. Surface that
194+
// as a distinct error so the UI can tell the user "this looks like an IG
195+
// export but the format may have changed" instead of an empty dashboard.
196+
if (isAnalysisEmpty(analysis)) {
197+
throw new Error("EMPTY_DATA");
198+
}
199+
175200
return { analysis, insights };
176201
}

src/locales/en.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@
4848
"INVALID_ZIP": "This doesn't look like an Instagram data export. Make sure you downloaded the ZIP from Instagram.",
4949
"UNSUPPORTED_FORMAT": "Please upload a .zip file from Instagram's data export.",
5050
"FILE_TOO_LARGE": "File is too large. Maximum allowed size is 500 MB.",
51+
"EMPTY_DATA": "We could read the ZIP, but it didn't contain any followers or following data. Instagram may have changed their export format — please request a fresh export, and if the problem persists, open an issue on GitHub.",
5152
"default": "Something went wrong. Please try again with a valid Instagram data export."
5253
}
5354
},

src/locales/ko.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@
4848
"INVALID_ZIP": "인스타그램 데이터 내보내기 파일이 아닌 것 같습니다. 인스타그램에서 다운로드한 ZIP 파일인지 확인해주세요.",
4949
"UNSUPPORTED_FORMAT": "인스타그램 데이터 내보내기에서 받은 .zip 파일을 올려주세요.",
5050
"FILE_TOO_LARGE": "파일이 너무 큽니다. 최대 허용 크기는 500 MB입니다.",
51+
"EMPTY_DATA": "ZIP은 정상적으로 읽었지만 팔로워/팔로잉 데이터가 들어있지 않습니다. 인스타그램이 내보내기 형식을 바꿨을 수 있습니다 — 다시 다운로드해보시고, 그래도 같은 문제라면 GitHub 이슈로 알려주세요.",
5152
"default": "문제가 발생했습니다. 유효한 인스타그램 데이터 파일로 다시 시도해주세요."
5253
}
5354
},

0 commit comments

Comments
 (0)