#!/usr/bin/env python3 """검수용 시연 데이터를 Firestore 에 넣는다. 발주사가 설치본으로 화면을 확인하실 수 있어야 하는데(별첨 2 2주차 「앱 설치·로그인· 탭 이동 확인」), 빈 프로젝트에서는 모든 화면이 빈 상태로 뜬다. 그래서 검수 프로젝트에만 시연 데이터를 넣는다. **운영 데이터가 아니다.** 인증은 firebase-tools 가 보관한 dev@sweech.io 액세스 토큰을 쓴다. 별도 서비스 계정 키를 만들지 않으려는 것이며, 토큰은 파일에 남기지 않는다. python3 seed-demo.py [--uid <가입한 uid>] uid 를 주면 그 사용자의 지갑·행성·소속을 함께 만든다. 주지 않으면 공용 컬렉션만 넣는다. """ from __future__ import annotations import argparse import json import os import sys import urllib.error import urllib.request from datetime import datetime, timedelta, timezone PROJECT = "fanuzu-dev-b020d" BASE = f"https://firestore.googleapis.com/v1/projects/{PROJECT}/databases/(default)/documents" KST = timezone(timedelta(hours=9)) def access_token(email: str = "dev@sweech.io") -> str: """firebase-tools 토큰 저장소에서 액세스 토큰을 꺼낸다.""" path = os.path.expanduser("~/.config/configstore/firebase-tools.json") store = json.load(open(path, encoding="utf-8")) if store.get("user", {}).get("email") == email: return store["tokens"]["access_token"] for account in store.get("additionalAccounts", []): if account.get("user", {}).get("email") == email: return account["tokens"]["access_token"] raise SystemExit(f"{email} 토큰을 찾지 못했습니다. firebase login:add 를 먼저 하십시오.") class GeoPoint: """좌표. Firestore 는 지도(map)가 아니라 geoPointValue 로 받아야 하며, 서버가 `place.get('geo').latitude` 로 읽으므로 형이 다르면 반경 판정이 조용히 실패한다.""" def __init__(self, lat: float, lng: float) -> None: self.lat = lat self.lng = lng def value(v): """파이썬 값 → Firestore REST 의 Value 표현.""" if isinstance(v, GeoPoint): return {"geoPointValue": {"latitude": v.lat, "longitude": v.lng}} if isinstance(v, bool): return {"booleanValue": v} if isinstance(v, int): return {"integerValue": str(v)} if isinstance(v, float): return {"doubleValue": v} if isinstance(v, datetime): return {"timestampValue": v.astimezone(timezone.utc).isoformat().replace("+00:00", "Z")} if isinstance(v, list): return {"arrayValue": {"values": [value(x) for x in v]}} if isinstance(v, dict): return {"mapValue": {"fields": {k: value(x) for k, x in v.items()}}} return {"stringValue": str(v)} def put(token: str, path: str, fields: dict) -> None: """문서 하나를 통째로 덮어쓴다. 여러 번 돌려도 결과가 같도록 PATCH 를 쓴다.""" url = f"{BASE}/{path}" body = json.dumps({"fields": {k: value(v) for k, v in fields.items()}}).encode() req = urllib.request.Request(url, data=body, method="PATCH") req.add_header("Authorization", f"Bearer {token}") req.add_header("Content-Type", "application/json") try: urllib.request.urlopen(req, timeout=30).read() print(f" ✔ {path}") except urllib.error.HTTPError as e: print(f" ✖ {path} — {e.code} {e.read().decode()[:200]}", file=sys.stderr) raise NOW = datetime.now(KST) FANDOM = "fanuzu-demo" # FPI 5 축 — 별첨 B 「FPI 5 축」 확정. 순서를 여기서 한 번 고정하고 아래 표가 따른다. AXES = ["bpm", "health", "referral", "community", "contribution"] # 갤럭시에 뜨는 행성들. `fpiScore` 는 5 축 단순 합산이다(별첨 B · 설계서 §4.1, # 가중치 초기 1.0). 앱이 TOTAL FPI 를 축의 합으로 그리므로 여기서 어긋나면 # 지표 카드의 합과 TOTAL FPI 가 갈린다 — 발주사 검토회신서 4절의 지적 자리다. # # **축마다 1 위가 다르도록 짰다.** 「서로 다른 지표 화면에 동일한 Ranking 데이터가 # 반복된다」가 지적이었으므로, 시연 데이터에서 지표를 바꾸면 순서가 바뀌는 것이 # 눈으로 확인돼야 한다. FANDOMS = [ # id, 이름, 축값[5], 멤버, 접속, 티커, 직전 축별 순위(순위 동향 ▲▼ 용) (FANDOM, "FANUZU 데모 행성", [24_800, 31_200, 18_400, 27_600, 26_400], 12_840, 431, "오늘의 기여가 132% 늘었습니다", [3, 1, 4, 1, 2]), ("aurora", "오로라", [31_400, 12_800, 22_600, 15_200, 14_200], 8_310, 214, "", [1, 4, 1, 3, 2]), ("nova-crew", "노바 크루", [9_800, 24_600, 11_200, 19_400, 9_800], 6_002, 158, "", [3, 2, 4, 2, 3]), ("stellar", "스텔라", [7_200, 8_400, 25_800, 6_300, 3_600], 4_115, 96, "", [4, 5, 2, 5, 5]), ("lumen", "루멘", [5_100, 6_200, 4_300, 11_400, 6_700], 2_884, 61, "", [5, 5, 5, 4, 4]), ] # 데모 행성의 멤버. 사용자 지표 랭킹(Figma 546:14668·14674)의 원천이다. # 국가 랭킹(546:14713)은 이 표를 국가별로 합산해 만든다 — 따로 손으로 적으면 # 두 화면의 수치가 갈린다. MEMBERS = [ # uid, 닉네임, 국가, 축값[5] ("demo-bunnies", "별빛수집가", "KR", [4_820, 2_110, 1_240, 980, 1_530]), ("demo-carat", "달빛", "KR", [3_960, 3_480, 620, 2_140, 870]), ("demo-moomin", "코스모스", "JP", [1_880, 4_020, 940, 3_050, 760]), ("demo-bora", "은하수", "JP", [1_210, 870, 2_260, 1_180, 2_980]), ("demo-dive", "네뷸라", "US", [940, 1_640, 1_720, 610, 1_120]), ("demo-nova", "혜성", "", [520, 780, 430, 340, 610]), ] def axis_map(values): """축값 5개를 `fpiAxes` 맵으로.""" return dict(zip(AXES, values)) # (문서 ID, 제목, 보상 POP, 일일 한도, 진행 라벨, Quest Type) # # `type` 은 앱이 **어느 검증 함수를 부를지** 고르는 값이다(설계서 §2.8). 비워 두면 앱이 # 판정 경로를 몰라 참여 버튼을 잠그고 사유를 표시한다 — 임의로 붙이면 검증 없는 지급 # 경로가 생기므로 비워 두는 쪽이 맞다. 라운지·응원처럼 Community 유형은 목표 수치 정의가 # 회신 대기(Q-QC-08)라 실제로 비어 있는 상태다. QUESTS = [ ("daily-checkin", "출석 체크", 50, 1, "1/1", "attend"), ("daily-steps", "8,000보 걷기", 120, 1, "6,240 / 8,000", "walk"), ("daily-place", "홍대 팝업스토어 방문", 100, 1, "0 / 1", "location"), ("daily-post", "라운지에 글 남기기", 100, 1, "0 / 1", ""), ("daily-cheer", "다른 행성 응원하기", 60, 3, "1 / 3", ""), ] # 행성 성장 단계 — 설계서 §2.3 `planetStages/{stageNo}`. # # 값의 출처는 **발주사 제공 수치표 v1.4 「Stage Summary」** 시트다(구간 기본 POP·sFPI). # 설계서의 `requiredPop` 은 「해당 Level 진입 **누적** 기여 POP」이므로 시트의 구간값을 # 누계로 바꿔 적었다. 합계는 시트의 합계 행과 같다 — 50,000,000 POP · 7,500,000 sFPI. # # Stage 번호는 별첨 B **B-53** 을 따른다. 성운은 Stage0 으로 빼고 정식 행성이 1~6 이다 # (시트의 1~7 번호와 한 칸 어긋난다). 성운 → 정식 행성 전환 조건은 이 표가 아니라 # B-10(10 명 + 1,000 POP + 7 일)이라 Stage0 의 문턱은 0 이다. # # (stageNo, levelFrom, levelTo, 진입 누적 POP, 진입 누적 sFPI) PLANET_STAGES = [ (0, None, None, 0, 0), # 태동 — 성운(Pre-Planet) (1, 1, 20, 0, 0), # 시작 (2, 21, 40, 100_000, 15_000), # 성장 (3, 41, 60, 500_000, 75_000), # 함께 (4, 61, 80, 2_000_000, 300_000), # 동행 (5, 81, 99, 10_000_000, 1_500_000), # 빛 (6, 100, 100, 50_000_000, 7_500_000), # 영원 ] # AI Daily Briefing 표본 — 정책 원장 B-47 · B-75. # 문안은 발주사 Agent 엔진이 만들고 우리는 수신·승인·노출만 한다(B-48). 표본도 # 그 규격대로 5 요소(출처명·게시시각·원본 URL·원문보기·AI 요약)를 전건 채운다 — # 하나라도 비면 앱이 그 줄을 버리므로 데모에서 카드가 사라진다. BRIEFINGS = [ ("News", 1, "데뷔 10주년 단독 콘서트 서울 이틀 확정", "연합뉴스", "https://example.com/news/anniversary-concert", "서울 공연 이틀로 확정되었고 예매는 다음 주에 열립니다."), ("News", 2, "신곡, 주요 음원 차트 상위권 진입", "스포티파이 차트", "https://example.com/news/chart-entry", "발매 당일 글로벌 차트 상위권에 올랐습니다."), ("SNS", 1, "공식 계정, 팬미팅 비하인드 공개 예고", "공식 X 계정", "https://example.com/sns/behind-teaser", "비하인드 영상 공개 일정이 예고되었습니다."), ] QUESTIONS = [ ("데뷔 초기 무대 영상 어디서 볼 수 있나요", "공식 채널에 남아 있는 영상이 있는지 찾고 있습니다.", "별빛수집가", 500, 4), ("응원봉 배터리 교체 방법 아시는 분", "공식 안내가 없어서 직접 해보신 분 계신지 여쭙니다.", "달빛", 300, 7), ("팬미팅 대기 순서는 어떻게 정해지나요", "선착순인지 추첨인지 정확히 아는 분 계실까요.", "코스모스", 200, 2), ] POSTS = [ ("오늘 방송 보신 분", "마지막 곡 편곡이 완전히 달라졌더라고요. 다시 듣고 왔습니다.", "별빛수집가", 42, 8), ("행성 3단계 축하합니다", "다들 고생 많으셨어요. 다음 목표도 금방일 것 같습니다.", "달빛", 128, 23), ("굿즈 실물 후기", "생각보다 크기가 커서 놀랐습니다. 마감도 깔끔해요.", "코스모스", 76, 14), ("걷기 퀘스트 같이 하실 분", "혼자 하니 자꾸 까먹네요. 인증 스레드 만들어요.", "은하수", 55, 31), ] def main() -> None: ap = argparse.ArgumentParser() ap.add_argument("--uid", help="가입한 사용자 uid — 지갑·소속·행성을 함께 만든다") args = ap.parse_args() token = access_token() # 별첨 B v1.4 — STATION 은 1차 비노출. 문서를 미리 만들어 두어야 # 나중에 "앱 재배포 없이" 값 하나만 뒤집을 수 있다. print("▸ 기능 스위치(config/features)") put(token, "config/features", {"stationEnabled": False}) # 정책 원장 C-01 · C-02 — 4 카테고리 ↔ 10 활동 · 순서 · 노출 · 사용자 생성 허용(S03 초기값). # 서버 `DEFAULT_QUEST_CATALOG`(functions/src/quest/ugc.ts)와 같은 값이다. 운영 변경은 fn-admin-write. print("▸ 퀘스트 분류 마스터(config/questCatalog)") field = lambda f: [{"field": f, "required": True}] if f else [] put(token, "config/questCatalog", { "categories": [ {"key": k, "label": label, "order": i + 1, "visible": True} for i, (k, label) in enumerate([ ("health", "건강"), ("planet", "행성 활동"), ("community", "커뮤니티"), ("location", "특정 위치"), ]) ], "activities": [ {"key": k, "categoryKey": c, "order": o, "visible": True, "ugcAllowed": u, "inputSchema": field(f), **({"minReactions": 1} if k == "community" else {})} for k, c, o, u, f in [ ("walk", "health", 1, False, "goal"), ("shake", "health", 2, False, "goal"), ("streaming", "planet", 1, False, "link"), ("youtube", "planet", 2, False, "link"), ("vote", "planet", 3, True, "link"), ("sns", "planet", 4, True, "link"), ("community", "community", 1, True, None), ("location", "location", 1, True, "place"), # 위치 무관 QR / NFC — 검증 명세 확정 전 사용자 생성 비노출(C-03). ("qr", "location", 2, False, "code"), ("nfc", "location", 3, False, "tag"), ] ], }) print("▸ 행성(fandoms)") # 축별 순위는 서버(fn-fpi-aggregate)가 매기는 값이다. 시드도 같은 방식으로 # 계산해 넣는다 — 손으로 적으면 값과 순위가 갈린다. ranks = { axis: { fid: i + 1 for i, (fid, *_) in enumerate( sorted(FANDOMS, key=lambda f: -f[2][a]) ) } for a, axis in enumerate(AXES) } # 생성 팔레트 키(C-50 · 앱 planetColors) — 행성마다 돌려 쓴다. colors = ["blue", "purple", "gold", "green", "red", "orange", "white", "black"] for i, (fid, name, axes, members, online, ticker, prev) in enumerate(FANDOMS): color = colors[i % len(colors)] # 팬덤당 행성 1건 — 문서 ID 고정 `planet`(fn-planet-create 와 같은 자리 · 설계서 §2.4). put(token, f"fandoms/{fid}/planets/planet", { "ownerUid": "seed", "fandomId": fid, "planetType": "shape1", "colorKey": color, "createdAt": NOW, }) # 이름 중복 판정 문서(fn-nebula-create) — 시드 이름으로 성운을 또 만들지 못하게. put(token, f"fandomNames/{name.lower()}", {"fandomId": fid, "createdAt": NOW}) put(token, f"fandoms/{fid}", { "planetType": "shape1", "colorKey": color, "name": name, # TOTAL FPI = 5 축 단순 합산. "fpiScore": sum(axes), "fpiAxes": axis_map(axes), "fpiRanks": {axis: ranks[axis][fid] for axis in AXES}, "fpiRanksPrev": axis_map(prev), # 별첨 B 「FPI 랭킹」 모집단 — 정식 행성(Stage 1 이상) 중 # 최근 7 일 유효 활동 존재 행성. "stage": 1, "rankingEligible": True, "memberCount": members, "onlineCount": online, "liveTicker": ticker, "countryCode": "KR", "fpiUpdatedAt": NOW, }) print("▸ Daily FPI Snapshot — 일 1 회(별첨 B). 지표 카드의 「최근 동향」 기준선") # 앱의 최근 동향 = 현재 `fpiAxes` − 오늘 스냅샷. 스냅샷을 조금 낮게 두어 # 상승·하락이 섞여 보이게 한다. day_key = NOW.strftime("%Y-%m-%d") for fid, _name, axes, *_rest in FANDOMS: base = [v - d for v, d in zip(axes, [1_700, -1_150, 500, 1_800, 1_500])] put(token, f"fandoms/{fid}/fpiSnapshots/{day_key}", { "createdAt": NOW.replace(hour=0, minute=0, second=0, microsecond=0), "fpiAxes": axis_map(base), "fpiScore": sum(base), }) print("▸ 데모 행성 멤버 — 사용자 지표 랭킹의 원천") for muid, nickname, country, axes in MEMBERS: put(token, f"fandoms/{FANDOM}/members/{muid}", { "uid": muid, "fandomId": FANDOM, "nickname": nickname, # 국가 랭킹 집계가 읽는 비정규화 필드. 빈 값은 ZZ(미지정) 버킷이다. "countryCode": country, "fpiAxes": axis_map(axes), "contributedPop": axes[4], "joinedAt": NOW - timedelta(days=20), }) print("▸ 국가별 지표 랭킹 — 축마다 문서 1건(설계서 §10)") for a, axis in enumerate(AXES): totals = {} for _muid, _nick, country, axes in MEMBERS: totals[country or "ZZ"] = totals.get(country or "ZZ", 0) + axes[a] rows = sorted(totals.items(), key=lambda kv: -kv[1]) put(token, f"rankings/country-{axis}", { "axis": axis, "updatedAt": NOW, # 표시명은 넣지 않는다 — 앱의 `countryName` 한 곳이 코드를 이름으로 # 옮긴다. 여기에도 이름을 적으면 표기가 두 벌이 된다. "rows": [ { "code": code, "value": value, "rank": i + 1, # 시드에는 직전 집계가 없으므로 동향은 0 이다. "change": 0, } for i, (code, value) in enumerate(rows) ], }) print("▸ 퀘스트") for qid, title, reward, limit, label, qtype in QUESTS: fields = { "title": title, "rewardPop": reward, "dailyLimit": limit, "progressLabel": label, "active": True, } if qtype: fields["type"] = qtype if qid == "daily-place": # 위치 퀘스트는 장소 문서를 가리켜야 서버가 반경을 판정할 수 있다. fields["placeId"] = "hongdae-popup" put(token, f"quests/{qid}", fields) print("▸ 행성 성장 단계 — 발주사 수치표 v1.4 「Stage Summary」") for stage_no, level_from, level_to, pop, sfpi in PLANET_STAGES: fields = {"stageNo": stage_no, "requiredPop": pop, "requiredSfpi": sfpi} if level_from: fields["levelFrom"] = level_from fields["levelTo"] = level_to put(token, f"planetStages/{stage_no}", fields) print("▸ 위치 퀘스트 장소 — 반경은 Admin 운영값(별첨 B B-27 기본 100m)") put(token, "places/hongdae-popup", { "name": "홍대 팝업스토어", "geo": GeoPoint(37.5563, 126.9236), "radiusM": 100, "rewardPop": 100, "verifyMethods": ["gps", "qr"], }) print("▸ 브리핑") # 앱은 `status == published` 이고 `dayKey` 가 당일(KST)인 것만 읽는다(B-47). # 시드가 그 두 필드를 채우지 않으면 데모에서 브리핑 블록이 숨는다. day_key = NOW.strftime("%Y-%m-%d") for section, rank, headline, source_name, url, summary in BRIEFINGS: put(token, f"briefings/{FANDOM}_{day_key}_{section}_{rank}", { "fandomId": FANDOM, "dayKey": day_key, "section": section, "rank": rank, "headline": headline, "status": "published", "sourceName": source_name, "sourceAt": NOW - timedelta(hours=rank), "sourceUrl": url, "articleUrl": url, "aiSummary": summary, "aiGenerated": True, "createdAt": NOW, "publishedAt": NOW, }) print("▸ 캠페인") put(token, "campaigns/demo-campaign", { "fandomId": FANDOM, "status": "funding", "campaignType": "support", "title": "데뷔 5주년 지하철 광고", "authorName": "별빛수집가", "description": "역사 스크린에 5주년 축하 영상을 올립니다. 모인 POP 만큼 노출 일수가 늘어납니다.", "goalPop": 500_000, "raisedPop": 341_200, "donorCount": 284, "commentCount": 47, "likeCount": 512, "achievedRate": 68, "createdAt": NOW - timedelta(days=6), "endsAt": NOW + timedelta(days=9), }) print("▸ 질문(INSIDE)") for i, (title, body, author, bounty, answers) in enumerate(QUESTIONS): put(token, f"questions/q-{i}", { "fandomId": FANDOM, "status": "open", "category": "general", "title": title, "body": body, "authorName": author, "bountyPop": bounty, "answerCount": answers, "createdAt": NOW - timedelta(hours=2 + i * 5), }) print("▸ 라운지 글") for i, (title, body, author, likes, comments) in enumerate(POSTS): put(token, f"posts/post-{i}", { # 설계서 §2.6 — visible | hidden | deleted. 규칙이 새 글을 visible 로만 만든다. "fandomId": FANDOM, "visibility": "visible", "authorUid": f"demo-author-{i}", "title": title, "body": body, "authorName": author, "likeCount": likes, "commentCount": comments, "createdAt": NOW - timedelta(hours=1 + i * 4), }) print("▸ STATION 큐레이션 링크") for i, (title, url, category, desc, verified) in enumerate([ ("FANUZU 공식 사이트", "https://fanuzu.co.kr", "official", "공지와 일정이 가장 먼저 올라옵니다.", True), ("공식 스토어", "https://fanuzu.co.kr", "shop", "앨범과 응원봉을 파는 공식 판매처입니다.", True), ("팬미팅 예매", "https://fanuzu.co.kr", "ticket", "예매 일정과 대기 순서를 확인하십시오.", False), ("공식 미디어 채널", "https://fanuzu.co.kr", "media", "무대 영상과 비하인드가 올라옵니다.", True), ]): put(token, f"stationLinks/link-{i}", { "fandomId": FANDOM, "rank": i + 1, "title": title, "url": url, "category": category, "description": desc, "verified": verified, }) print("▸ 갤럭시 이벤트") put(token, "galaxyEvents/evt-latest", { "kind": "itemUsed", "message": "오로라가 성장 촉진 아이템을 사용했습니다", "createdAt": NOW - timedelta(minutes=18), }) print("▸ 이클립스 운영값 — Admin 「관리 마스터_이클립스 관리」 항목") # **`enabled` 를 끈 채 심는다.** 켜 두면 `fn-eclipse-trigger` 가 발동 시각에 # 이클립스를 하나 더 만들어, 아래에서 손으로 심는 시연용 이클립스와 둘이 동시에 # 활성이 된다. 발동 스케줄을 확인할 때 이 값을 켠다. put(token, "config/eclipse", { "enabled": False, # 정책 원장 C-24 — 공통 일정(배열 길이 = 하루 회차 수). 팬덤별은 fandoms.eclipseSchedule[]. "schedule": [{"hhmm": "07:50"}, {"hhmm": "17:50"}], "erosionPercent": 0.25, # C-23 실패 감소율(%) "warnSeconds": 180, # C-22 발생 3분 전 푸시 "windowSec": 60, # C-22 공통 방어 창(초) "targetRate": 0.05, # C-25 7일 평균 DAU 대비 목표율 "dauWindowDays": 7, # C-21 DAU 평균 기간(일) "qualifyShakes": 60, # C-25 개인 인정 유효 Shake "perCallMaxShakes": 50, "perUserMaxDefense": 500, "minIntervalMs": 300, }) print("▸ 활성 이클립스 — 경보 배너·침식 HUD·ZAP 방어의 대상") # 샤드는 비워 둔다. `fn-eclipse-defend` 가 채우고 `fn-eclipse-aggregate` 가 # 합산하는 것이 확인 항목이므로, 여기서 채워 두면 방어가 반영되는지를 못 본다. # 시연 편의로 창을 30분 열어 둔다(운영 기본은 60초). 목표 2명 · 인정 60회(C-25·C-26). put(token, f"fandoms/{FANDOM}/eclipseRounds/ecl-demo", { "status": "active", "fandomName": "BTS", "target": 2, "qualifyShakes": 60, "windowSec": 1800, "defense": 0, "defenseCount": 0, "erosionRate": 100, "defended": False, "startAt": NOW - timedelta(minutes=6), "endsAt": NOW + timedelta(minutes=24), }) print("▸ 라이브 소셜 프루프 — 홈 라이브 띠·갤럭시 섬광 배너") # 별첨 B 「Global Ticker」 — 이 컬렉션은 이클립스 전용이 아니다. `kind` 로 갈린다. for i, (nickname, defense, minutes) in enumerate([ ("달빛", 30, 1), ("오로라", 10, 4), ("버니즈", 50, 9), ]): at = NOW - timedelta(minutes=minutes) put(token, f"fandoms/{FANDOM}/liveEvents/live-{i}", { "kind": "eclipse_defense", "eclipseId": "ecl-demo", "uid": f"seed-{i}", "nickname": nickname, "defense": defense, "priority": 20, "linkTo": f"/eclipse/{FANDOM}/ecl-demo", "createdAt": at, "expiresAt": at + timedelta(hours=1), }) if args.uid: uid = args.uid print(f"▸ 사용자 {uid}") # 대표 팬덤(C-15) — primaryFandomId 가 정본, fandomId 는 옛 독자용 같은 값. put(token, f"users/{uid}", { "primaryFandomId": FANDOM, "fandomId": FANDOM, "status": "active", "createdAt": NOW, }) put(token, f"users/{uid}/wallet/main", {"activityBalance": 12_400, "supportBalance": 3_000}) # GALAXY 「가입 또는 생성한 행성/성운」 목록(명세 §2.58)의 원천. put(token, f"users/{uid}/memberships/{FANDOM}", { "fandomId": FANDOM, "joinedAt": NOW - timedelta(days=12), }) put(token, f"fandoms/{FANDOM}/members/{uid}", { "uid": uid, "fandomId": FANDOM, "nickname": "나", "countryCode": "KR", # 지표마다 내 순위가 달라야 한다 — REFERRAL·CONTRIBUTION 은 1 위, # BPM 은 3 위가 되도록 잡았다. "fpiAxes": axis_map([2_740, 1_290, 3_180, 1_620, 4_210]), # 지표 카드의 「내 기여 상위 n%」. 서버 산출값이며 멤버 순위에서 나온다. "topPercentByAxis": axis_map([43, 72, 15, 43, 15]), "contributedPop": 4_210, "joinedAt": NOW - timedelta(days=12), }) for qid, *_ in QUESTS[:2]: put(token, f"users/{uid}/questProgress/{qid}", {"count": 1, "dayKey": NOW.strftime("%Y-%m-%d")}) print("\n완료했습니다.") if __name__ == "__main__": main()