`;
}).join("");
}
@@ -781,8 +789,7 @@
/* the legend doubles as a filter: pick a track to keep only its line */
function renderLegend() {
const count = (id) => [...state.repos.values()].filter((r) => (r.lines || []).includes(id)).length;
- $("#line-legend").innerHTML = state.lineList.map((l) => ``).join("") +
- `
From 66b3e637e52205c0606371d803d24ceccf456a92 Mon Sep 17 00:00:00 2001
From: jsshin2022 <59598545+jsshin2022@users.noreply.github.com>
Date: Sun, 25 Oct 2026 05:50:01 +0000
Subject: [PATCH 2/4] Keep progress in the passport and make WORLD 2 quests
things to do
Clearing every quest in a stage now stamps the passport, which gains a
roadmap group of five stage stamps; the tracker beside the tab note opens
the passport instead of keeping its own count. L3 and L4 quests are the
passport's comment, first PR, runner and Builder stamps, with their repos
listed below as the stage's repos. The goal chips give way to a single
start-finder button, with a way back while a suggested route is shown.
---
CONTRIBUTING.md | 2 +-
README.md | 18 ++++-----
assets/css/hub.css | 15 +++++++
assets/js/hub.js | 92 ++++++++++++++++++++++++++++++++-----------
assets/js/passport.js | 10 ++++-
data/levels.json | 12 +++++-
index.html | 9 ++---
scripts/check_data.py | 2 +
8 files changed, 118 insertions(+), 42 deletions(-)
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index a378f6a..20c4d7b 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -83,7 +83,7 @@ python3 scripts/builder_cards.py
## 활동 여권과 실험실
-스탬프 정의는 `assets/js/passport.js`의 `STAMPS`에 있습니다. `kind: "auto"`는 허브가 알아서 찍고(`window.hubStamp("post")`), `kind: "self"`는 행사 후기, 강의 후기, 레포 댓글처럼 허브 밖에서 하는 일이라 방문자가 ‘했어요’를 눌러 찍습니다. 이미 받은 스탬프는 다시 주지 않고, 기록은 `localStorage`에만 남습니다. 웹북 독자 스탬프는 같은 도메인의 웹북이 남기는 `cil-book-read`(읽은 장)를 보고 찍고, 역 정보 창의 '내 진도'와 '이어 읽기'는 `cil-book-read`, `cil-book-last`를 읽습니다. `/#passport`로 들어오면 여권이 바로 열립니다. 교란 실험실(`lab.html`, `assets/js/lab.js`)의 데이터는 페이지에서 만드는 가상 사용자입니다.
+스탬프 정의는 `assets/js/passport.js`의 `STAMPS`에 있습니다. `kind: "auto"`는 허브가 알아서 찍고(`window.hubStamp("post")`), `kind: "self"`는 행사 후기, 강의 후기, 레포 댓글처럼 허브 밖에서 하는 일이라 방문자가 ‘했어요’를 눌러 찍습니다. 로드맵 스테이지 스탬프(`stage_L0`–`stage_L4`)는 그 스테이지의 퀘스트를 모두 클리어하면 허브가 찍습니다. L3·L4처럼 `data/levels.json`에 `quests`(스탬프 id 목록)가 있는 스테이지는 그 스탬프들이 퀘스트이고, 없는 스테이지는 무료 자료가 퀘스트입니다. 이미 받은 스탬프는 다시 주지 않고, 기록은 `localStorage`에만 남습니다. 웹북 독자 스탬프는 같은 도메인의 웹북이 남기는 `cil-book-read`(읽은 장)를 보고 찍고, 역 정보 창의 '내 진도'와 '이어 읽기'는 `cil-book-read`, `cil-book-last`를 읽습니다. `/#passport`로 들어오면 여권이 바로 열립니다. 교란 실험실(`lab.html`, `assets/js/lab.js`)의 데이터는 페이지에서 만드는 가상 사용자입니다.
## 이벤트 측정
diff --git a/README.md b/README.md
index c44191e..17d52ca 100644
--- a/README.md
+++ b/README.md
@@ -41,26 +41,26 @@
- **교란 실험실:** 가상 사용자 800명으로 교란이 단순 비교를 얼마나 속이는지 슬라이더로 직접 움직여 봅니다. [실험실 열기](https://causalinferencelab.github.io/lab.html)
- **연재:** 팀원이 쓰는 글을 모읍니다. 매주 월요일 ‘이번 주 포스트’가 바뀌고, 글쓴이를 작가 카드로 소개합니다.
-- **활동 여권:** 자료 읽기, 웹북 한 장 완독 같은 배우기 스탬프와 함께, 행사 사진 후기, 강의·책 후기, 레포 댓글, 러너 참여, 첫 기여, Builder 되기 스탬프를 모읍니다. 기록은 내 브라우저에만 남습니다.
+- **활동 여권:** 로드맵 스테이지 클리어 스탬프 다섯 개, 자료 읽기와 웹북 한 장 완독 같은 배우기 스탬프와 함께, 행사 사진 후기, 강의·책 후기, 레포 댓글, 러너 참여, 첫 기여, Builder 되기 스탬프를 모읍니다. 기록은 내 브라우저에만 남습니다.
- **3문항으로 시작점 찾기:** 경험, 하는 일, 원하는 것을 고르면 볼 순서와 첫 자료를 추천합니다. [바로 해 보기](https://causalinferencelab.github.io/?quiz=1)
- **상관일까, 인과일까?:** 데이터 상황 10개를 보고 상관인지 인과인지 고르는 2분짜리 게임입니다. 답마다 관련 웹북 장으로 이어집니다. [게임 하기](https://causalinferencelab.github.io/game.html)
- **완주 카드:** 한 단계의 자료를 모두 학습 완료로 표시하면, 공유할 수 있는 완주 카드 이미지를 만들 수 있습니다.
- **Builder 페이지:** 프로젝트를 연 Builder마다 이력, 만든 레포, 배지를 모은 페이지가 있습니다. [Builder 전체 보기](https://causalinferencelab.github.io/builders/)
- **Builder 메달:** Builder로 연 기수가 쌓이면 메달이 바뀝니다. 첫 기수는 Builder, 2개 기수는 다시 온 Builder, 3개 기수 이상은 베테랑 Builder, 5개 기수 이상은 명예 Builder입니다. 러너 20명 이상과 함께한 멘토, 러너로 시작해 Builder가 된 사람에게는 특별 메달이 붙습니다. 메달 카드 이미지는 허브에서 바로 만들 수 있습니다.
-## 커리큘럼: L0–L4
+## 커리큘럼: L0–L4 로드맵
-L0–L2는 **배우는 단계**로, 무료 자료만으로 끝까지 갈 수 있습니다. L3–L4는 **만드는 단계**입니다.
+다섯 단계를 한 길 위의 스테이지로 엮었습니다. L0–L2(WORLD 1 · 배우기)는 무료 자료가 퀘스트이고, 무료 자료만으로 끝까지 갈 수 있습니다. L3–L4(WORLD 2 · 만들기)는 이슈 댓글, 첫 PR, 러너 참여, Builder 되기처럼 직접 하는 일이 퀘스트입니다. 스테이지의 퀘스트를 모두 클리어하면 활동 여권에 스테이지 스탬프가 찍힙니다.
| 단계 | 질문 | 대표 자료 |
| --- | --- | --- |
-| L0 Foundation | 왜 인과추론인가 | [Causal Studio](https://github.com/CausalInferenceLab/causal-studio) |
-| L1 Theory & Concept | 어떻게 추정하나 | [Brave & True 인과추론 번역서](https://github.com/CausalInferenceLab/Causal-Inference-with-Python), [온라인 실험 이해하기](https://github.com/CausalInferenceLab/OCE-Materials) |
-| L2 Application | 내 데이터에 어떻게 쓰나 | [책 예제 코드](https://github.com/CausalInferenceLab/causal-inference-in-python-code), [산업 사례 템플릿](https://github.com/CausalInferenceLab/causal-inference-practice), [특강·발표 자료](https://github.com/CausalInferenceLab/causal-inference-lecture) 외 |
-| L3 Amplification | 분석을 어떻게 키우나 | [Lang2SQL](https://github.com/CausalInferenceLab/Lang2SQL), [정책 효과 분석 에이전트](https://github.com/CausalInferenceLab/policy-effect-analytics-agent) |
-| L4 Research | 무엇이 아직 안 풀렸나 | [ccwr](https://github.com/CausalInferenceLab/ccwr), RWE 가이드라인·적응형 설계 스터디, 외부 오픈소스 기여 |
+| L0 입문 | 왜 인과추론인가 | [Causal Studio](https://github.com/CausalInferenceLab/causal-studio) |
+| L1 개념 완성 | 어떻게 추정하나 | [Brave & True 인과추론 번역서](https://github.com/CausalInferenceLab/Causal-Inference-with-Python), [온라인 실험 이해하기](https://github.com/CausalInferenceLab/OCE-Materials) |
+| L2 실전 적용 | 내 데이터에 어떻게 쓰나 | [책 예제 코드](https://github.com/CausalInferenceLab/causal-inference-in-python-code), [산업 사례 템플릿](https://github.com/CausalInferenceLab/causal-inference-practice), [특강·발표 자료](https://github.com/CausalInferenceLab/causal-inference-lecture) 외 |
+| L3 도구 개발 | 분석을 어떻게 키우나 | [Lang2SQL](https://github.com/CausalInferenceLab/Lang2SQL), [정책 효과 분석 에이전트](https://github.com/CausalInferenceLab/policy-effect-analytics-agent) |
+| L4 연구 | 무엇이 아직 안 풀렸나 | [ccwr](https://github.com/CausalInferenceLab/ccwr), RWE 가이드라인·적응형 설계 스터디, 외부 오픈소스 기여 |
-자료는 주제에 따라 여섯 트랙(입문·기본, ML, 연구, 실험, 행동·마케팅, AI 에이전트)으로도 나뉩니다. 허브에서 "처음 시작해요", "A/B 테스트를 해요" 같은 목표를 고르면 볼 순서를 안내합니다.
+자료는 주제에 따라 여섯 트랙(입문·기본, ML, 연구, 실험, 행동·마케팅, AI 에이전트)으로도 나뉩니다. 허브의 트랙 지도에서 노선을 고르면 그 주제만 따라갈 수 있고, 어디서 시작할지 모르겠다면 3문항으로 볼 순서를 추천받습니다.
## 한빛미디어와 함께
diff --git a/assets/css/hub.css b/assets/css/hub.css
index 69d3a56..d57ff25 100644
--- a/assets/css/hub.css
+++ b/assets/css/hub.css
@@ -1634,3 +1634,18 @@ button.btn.sm { font-size: 14px; }
/* pins keep white text readable in both themes */
.st-pin, .vl-pin { background: #4C4FD0; }
#metro .pin rect, #metro .pin path { fill: #4C4FD0; }
+
+/* ---------- one record (the passport), action quests on WORLD 2, the start finder beside the tab note ---------- */
+.note-row { margin: -6px 0 14px; }
+.note-row .tab-note { margin: 0; }
+.note-row .route-chips { gap: 6px; }
+.note-row .route-chips .chip { font-size: 13.5px; padding: 6px 12px; }
+.qh { all: unset; box-sizing: border-box; cursor: pointer; display: inline-flex; align-items: center; gap: 10px; padding: 7px 14px; border-radius: 999px; background: var(--surface); font-size: 13.5px; color: var(--muted); }
+.qh:hover { box-shadow: 0 4px 14px rgba(20, 22, 43, .08); }
+.qh:focus-visible { outline: 3px solid var(--focus); outline-offset: 2px; }
+.route-note:empty { display: none; }
+.lv-sub { margin: 18px 0 -6px; font-size: 13px; font-weight: 800; color: var(--muted); letter-spacing: .02em; }
+#shelf .ep.act { --c: var(--l-ai); }
+.aq-body { display: flex; flex-direction: column; gap: 10px; padding: 16px 18px 12px; flex: 1; }
+.aq-body .stamp { width: 44px; height: 44px; font-size: 16px; flex-shrink: 0; }
+.ep-clear:disabled { cursor: default; }
diff --git a/assets/js/hub.js b/assets/js/hub.js
index 04e396c..22dc749 100644
--- a/assets/js/hub.js
+++ b/assets/js/hub.js
@@ -373,11 +373,11 @@
const tryUrl = r.preview || (r.previewLabel ? r.site : "");
const tryLink = tryUrl ? `${esc(r.previewLabel || "맛보기")} ›` : "";
const done = state.ridden.has(id);
- const clearBtn = rideable(r) ? `` : "";
+ const clearBtn = isMaterialQuest(r) ? `` : "";
const acts = tryLink || clearBtn ? `
${tryLink}${clearBtn}
` : "";
return `
`;
}
+ /* a WORLD 2 quest: something done outside the hub, ticked here, kept as a passport stamp */
+ function actionQuestHTML(q, k, lv) {
+ const st = q.stamp, done = questDone(q);
+ return `
`;
+ }
/* what clearing a stage gives: a finish card to save and share */
function stageGoalHTML(lv) {
- const quests = questsOf(lv.id), done = quests.filter((r) => state.ridden.has(r.repo)).length;
- if (levelDone(lv.id)) return `
STAGE CLEAR${esc(lv.tier || lv.name)} 퀘스트 ${quests.length}개를 모두 마쳤습니다.
배우기 스탬프는 허브가 알아서 찍고, 함께하기와 기여하기는 하고 나서 ‘했어요’를 누르면 찍힙니다. 기록은 이 브라우저에만 저장됩니다.
+
로드맵 스탬프는 스테이지를 클리어하면, 배우기 스탬프는 허브가 알아서 찍고, 함께하기와 기여하기는 하고 나서 ‘했어요’를 누르면 찍힙니다. 기록은 이 브라우저에만 저장됩니다.
`;
if (!dlg.open) { if (typeof dlg.showModal === "function") dlg.showModal(); else dlg.setAttribute("open", ""); }
dlg.querySelector(".station-close").focus();
diff --git a/data/levels.json b/data/levels.json
index 42dcebe..3973b03 100644
--- a/data/levels.json
+++ b/data/levels.json
@@ -33,7 +33,11 @@
"question": "분석을 어떻게 키우나",
"goal": "데이터 에이전트처럼 분석을 빠르게 만드는 도구를 함께 개발합니다.",
"layer": "build",
- "for": "분석을 돕는 도구를 직접 만들고 싶은 분"
+ "for": "분석을 돕는 도구를 직접 만들고 싶은 분",
+ "quests": [
+ "comment",
+ "first_pr"
+ ]
},
{
"id": "L4",
@@ -42,6 +46,10 @@
"question": "무엇이 아직 안 풀렸나",
"goal": "연구 소프트웨어를 만들고 규제·임상 연구를 리뷰하며, 외부 오픈소스에 기여합니다.",
"layer": "build",
- "for": "헬스케어·규제 연구나 외부 오픈소스 기여를 하려는 분"
+ "for": "헬스케어·규제 연구나 외부 오픈소스 기여를 하려는 분",
+ "quests": [
+ "runner",
+ "open_project"
+ ]
}
]
diff --git a/index.html b/index.html
index bbcb89e..8e28cdd 100644
--- a/index.html
+++ b/index.html
@@ -74,19 +74,16 @@
Causal Inference Hub
커리큘럼
L0부터 L4까지, 팀이 만든 오픈소스를 순서대로 엮었습니다. L0–L2는 무료 자료만으로 끝까지 갈 수 있습니다.
-
- 무엇을 하고 싶나요?
-
-
-
로드맵은 배우는 순서이고, 트랙은 주제입니다. 같은 주제를 L0부터 L4까지 이어 보려면 를 여세요.
-
+
+
+
diff --git a/scripts/check_data.py b/scripts/check_data.py
index b6b5d83..3c847aa 100644
--- a/scripts/check_data.py
+++ b/scripts/check_data.py
@@ -71,6 +71,8 @@ def main():
levels = {lv.get("id") for lv in data.get("levels.json") or []}
for lv in data.get("levels.json") or []:
token(lv.get("id"), "levels.json", "id")
+ for q in lv.get("quests") or []:
+ token(q, f"levels.json {lv.get('id')!r}", "quests")
lines = {ln.get("id") for ln in data.get("lines.json") or []}
for ln in data.get("lines.json") or []:
token(ln.get("id"), "lines.json", "id")
From 54414afbd0f8cda87152705e6646e0d78c2f1f45 Mon Sep 17 00:00:00 2001
From: jsshin2022 <59598545+jsshin2022@users.noreply.github.com>
Date: Sun, 25 Oct 2026 11:54:03 +0000
Subject: [PATCH 3/4] Open the curriculum on the track map and call levels
Level 0 to 4
The track map is the first tab, and phones now get the map itself,
panned sideways, with the line-by-line list folded underneath. Levels
read as Level 0 to Level 4 across the roadmap, map, cards and finish
card. The roadmap copy is shorter: one line for the tabs, no stage
kicker, no tags on the cards, and a plain 'stamp when done' goal.
---
assets/css/hub.css | 22 ++++++++++++++++++--
assets/js/hub.js | 51 +++++++++++++++++++++++-----------------------
index.html | 24 +++++++++++-----------
3 files changed, 58 insertions(+), 39 deletions(-)
diff --git a/assets/css/hub.css b/assets/css/hub.css
index d57ff25..46b24f5 100644
--- a/assets/css/hub.css
+++ b/assets/css/hub.css
@@ -533,9 +533,15 @@ body::before { content: ""; position: fixed; inset: 0 0 auto; height: env(safe-a
/* Vertical lines replace the map on phones and small tablets */
.vlines { display: none; padding: 0 16px; }
+/* phones get the real map, panned sideways, and the line-by-line list folded under it */
+.vl-fold { display: none; }
@media (max-width: 900px) {
- .map-scroll, .map-hint { display: none !important; }
- .vlines { display: grid; gap: 22px; }
+ .vl-fold { display: block; margin: 10px 16px 0; border-radius: 12px; background: var(--surface-2); }
+ .vl-fold > summary { cursor: pointer; padding: 12px 14px; font-size: 14px; font-weight: 700; list-style: none; }
+ .vl-fold > summary::-webkit-details-marker { display: none; }
+ .vl-fold > summary::after { content: " ▾"; color: var(--muted); }
+ .vl-fold[open] > summary::after { content: " ▴"; }
+ .vl-fold .vlines { display: grid; gap: 22px; padding: 4px 14px 16px; }
}
.vline h3 { display: flex; align-items: center; gap: 8px; font-size: 16px; margin-bottom: 8px; }
.vline ol { list-style: none; margin: 0; padding: 0; }
@@ -1649,3 +1655,15 @@ button.btn.sm { font-size: 14px; }
.aq-body { display: flex; flex-direction: column; gap: 10px; padding: 16px 18px 12px; flex: 1; }
.aq-body .stamp { width: 44px; height: 44px; font-size: 16px; flex-shrink: 0; }
.ep-clear:disabled { cursor: default; }
+
+/* ---------- Level labels ---------- */
+.st-lv { margin-top: 8px; font-size: 12px; font-weight: 700; letter-spacing: .02em; color: var(--muted); }
+.st-lv + .st-name { margin-top: 0; }
+.lv-id { display: flex; flex-direction: column; align-items: flex-start; line-height: .9; }
+.lv-id small { font-family: var(--f-body); font-size: 13px; font-weight: 800; letter-spacing: .04em; color: var(--muted); }
+#metro .zone-l { font-size: 18px; }
+@media (max-width: 900px) {
+ .line-legend { flex-wrap: nowrap; overflow-x: auto; scrollbar-width: none; padding-bottom: 4px; }
+ .line-legend::-webkit-scrollbar { display: none; }
+ .line-legend li { flex-shrink: 0; }
+}
diff --git a/assets/js/hub.js b/assets/js/hub.js
index 22dc749..60dbd9e 100644
--- a/assets/js/hub.js
+++ b/assets/js/hub.js
@@ -334,6 +334,8 @@
}
const pad2 = (n) => String(n).padStart(2, "0");
+ /* levels read as "Level 0".."Level 4"; the ids stay L0..L4 in data and links */
+ const LV = (id) => (id ? `Level ${String(id).replace(/^L/, "")}` : "");
const DAY = 864e5;
const ageDays = (iso) => (iso ? Math.max(0, Math.floor((Date.now() - Date.parse(iso)) / DAY)) : null);
function ago(iso) {
@@ -367,7 +369,6 @@
const tracks = (r.lines || []).map((l) => esc(state.lines.get(l).name)).join(" · ");
const lv = state.levels.find((x) => x.id === level);
/* what a course says about itself: three tags, what it is made of, the book it follows, and a first look */
- const tags = (r.tags || []).length ? `${r.tags.map((t) => `#${esc(t)}`).join("")}` : "";
const book = r.textbook ? state.repos.get(r.textbook) : null;
const spec = r.format || book ? `${r.format ? `구성${esc(r.format)}` : ""}${book ? `교재${esc(book.shortTitle || book.title)}` : ""}` : "";
const tryUrl = r.preview || (r.previewLabel ? r.site : "");
@@ -376,9 +377,9 @@
const clearBtn = isMaterialQuest(r) ? `` : "";
const acts = tryLink || clearBtn ? `
`;
+ }
+
/* the next Data Forum gets the space; past ones are one line each */
function renderEvents() {
const list = [...state.events].sort((a, b) => b.order - a.order);
@@ -939,14 +1067,14 @@
const nextHTML = next ? `
` : "");
@@ -1548,7 +1676,7 @@
if (fa) track("click_featured", { repo: fa.dataset.repo || "" });
const out = e.target.closest(".pt-out");
if (out) track("click_partner", { item: out.closest(".pt-card").querySelector("[data-goto]").dataset.goto, action: "out" });
- const ja = e.target.closest("#join a, .footer-links a[href*='discord']");
+ const ja = e.target.closest("#join .steps a, #join .join-foot a, .footer-links a[href*='discord']");
if (ja) track("click_join", { target: joinTarget(ja.getAttribute("href") || ""), source: ja.closest("#join") ? "join_section" : "footer" });
const sa = e.target.closest("#station-body .links a");
if (sa && state.openRepo) track("click_outbound", { repo: state.openRepo.repo, kind: state.openRepo.kind === "paid" ? "paid" : /github\.com/.test(sa.href) ? "github" : "site" });
@@ -1645,6 +1773,18 @@
$("#curriculum .wrap").insertAdjacentHTML("afterbegin", `
데이터를 불러오지 못했습니다. 로컬에서 볼 때는 python3 -m http.server로 띄운 뒤 접속해 주세요.
`);
}
+ /* section_view: each section once per page view, when half of it (or most of the screen) is shown */
+ function watchSections() {
+ if (!("IntersectionObserver" in window)) return;
+ const seen = new Set();
+ const io = new IntersectionObserver((entries) => entries.forEach((en) => {
+ const id = en.target.id;
+ if (!en.isIntersecting || seen.has(id)) return;
+ seen.add(id); io.unobserve(en.target);
+ track("section_view", { section: id });
+ }), { threshold: [0.35] });
+ ["curriculum", "contrib", "activity", "events", "posts", "join"].forEach((id) => { const el = document.getElementById(id); if (el) io.observe(el); });
+ }
async function init() {
loadRidden(); loadRecent();
wire();
@@ -1661,6 +1801,7 @@
setHeader(); window.addEventListener("resize", setHeader);
renderLegend(); renderMap(); wireTabs();
renderFeatured(); renderPartner(); renderEvents(); renderNextEvent(); renderStages(); renderArticles(); renderDaily(); watchMapScroll();
+ renderLoop(); loadActivity(); watchSections();
/* sections fill in after the data loads, so jump again once they have their height */
if (/^#(contrib|events|reading|join)$/.test(location.hash)) requestAnimationFrame(() => { const el = $(location.hash); if (el) el.scrollIntoView(); });
openFromHash();
diff --git a/data/activity.json b/data/activity.json
new file mode 100644
index 0000000..a5a0b61
--- /dev/null
+++ b/data/activity.json
@@ -0,0 +1,4 @@
+{
+ "updated": null,
+ "items": []
+}
diff --git a/index.html b/index.html
index 37b4aaf..ab774bb 100644
--- a/index.html
+++ b/index.html
@@ -64,7 +64,8 @@
diff --git a/scripts/build_activity.py b/scripts/build_activity.py
new file mode 100644
index 0000000..1d0fec8
--- /dev/null
+++ b/scripts/build_activity.py
@@ -0,0 +1,134 @@
+"""Collect the team's recent public GitHub activity for the hub's activity feed.
+
+Runs in GitHub Actions on every deploy (and daily) with GITHUB_TOKEN and writes
+data/activity.json. The hub also asks GitHub directly when a visitor opens it and
+uses this file when that call is refused, so the feed is never empty for long.
+Uses only the standard library.
+"""
+import json
+import os
+import sys
+import urllib.error
+import urllib.parse
+import urllib.request
+from datetime import datetime, timezone
+
+ORG = "CausalInferenceLab"
+API = "https://api.github.com"
+TOKEN = os.environ.get("GITHUB_TOKEN", "")
+KEEP = 60
+
+
+class KeepTokenOnHost(urllib.request.HTTPRedirectHandler):
+ """urllib copies Authorization to any redirect target; drop it when the host changes."""
+
+ def redirect_request(self, req, fp, code, msg, headers, newurl):
+ new = super().redirect_request(req, fp, code, msg, headers, newurl)
+ if new is not None and urllib.parse.urlsplit(newurl).hostname != urllib.parse.urlsplit(req.full_url).hostname:
+ new.remove_header("Authorization")
+ return new
+
+
+OPENER = urllib.request.build_opener(KeepTokenOnHost)
+
+
+def get(url):
+ if not url.startswith(API + "/"):
+ raise ValueError(f"refusing to call {url}")
+ headers = {"Accept": "application/vnd.github+json", "User-Agent": "cil-hub-activity"}
+ if TOKEN:
+ headers["Authorization"] = f"Bearer {TOKEN}"
+ with OPENER.open(urllib.request.Request(url, headers=headers), timeout=30) as res:
+ return json.load(res)
+
+
+def is_bot(login):
+ return not login or login.endswith("[bot]") or login in ("github-actions", "dependabot")
+
+
+def item(e):
+ """One event as the hub shows it, or None for events the feed leaves out."""
+ login = (e.get("actor") or {}).get("login", "")
+ full = (e.get("repo") or {}).get("name", "")
+ if is_bot(login) or "/" not in full:
+ return None
+ repo = full.split("/", 1)[1]
+ p = e.get("payload") or {}
+ t, action = e.get("type"), p.get("action")
+ base = {"id": e.get("id"), "login": login, "repo": repo, "at": e.get("created_at")}
+ if t == "PushEvent":
+ if p.get("ref") not in ("refs/heads/main", "refs/heads/master"):
+ return None
+ return {**base, "kind": "push"}
+ if t == "PullRequestEvent":
+ pr = p.get("pull_request") or {}
+ n = p.get("number") or pr.get("number")
+ merged = action == "merged" or (action == "closed" and pr.get("merged"))
+ if action != "opened" and not merged:
+ return None
+ return {**base, "kind": "pr_merge" if merged else "pr_open", "number": n, "title": pr.get("title"),
+ "url": f"https://github.com/{full}/pull/{n}" if n else None}
+ if t == "IssuesEvent":
+ if action != "opened":
+ return None
+ issue = p.get("issue") or {}
+ return {**base, "kind": "issue", "number": issue.get("number"), "title": issue.get("title"), "url": issue.get("html_url")}
+ if t == "IssueCommentEvent":
+ issue, comment = p.get("issue") or {}, p.get("comment") or {}
+ return {**base, "kind": "comment", "number": issue.get("number"), "title": issue.get("title"),
+ "url": comment.get("html_url") or issue.get("html_url")}
+ if t == "PullRequestReviewEvent":
+ pr = p.get("pull_request") or {}
+ n = p.get("number") or pr.get("number")
+ return {**base, "kind": "review", "number": n, "title": pr.get("title"),
+ "url": f"https://github.com/{full}/pull/{n}" if n else None}
+ if t == "WatchEvent":
+ return {**base, "kind": "star"}
+ if t == "ForkEvent":
+ return {**base, "kind": "fork"}
+ if t == "ReleaseEvent":
+ rel = p.get("release") or {}
+ return {**base, "kind": "release", "title": rel.get("tag_name"), "url": rel.get("html_url")}
+ if t == "CreateEvent" and p.get("ref_type") == "repository":
+ return {**base, "kind": "repo"}
+ return None
+
+
+def add_titles(items):
+ """Org events leave out pull request titles; ask for the few that are shown."""
+ cache = {}
+ for it in items:
+ if it.get("title") or not it.get("number") or it["kind"] not in ("pr_open", "pr_merge", "review"):
+ continue
+ key = (it["repo"], it["number"])
+ if key not in cache:
+ try:
+ cache[key] = get(f"{API}/repos/{ORG}/{it['repo']}/pulls/{it['number']}").get("title")
+ except (urllib.error.HTTPError, urllib.error.URLError) as e:
+ print(f"skip title for {key}: {e}", file=sys.stderr)
+ cache[key] = None
+ it["title"] = cache[key]
+
+
+def main(out_path):
+ events = []
+ for page in (1, 2, 3):
+ try:
+ batch = get(f"{API}/orgs/{ORG}/events?per_page=100&page={page}")
+ except urllib.error.HTTPError as e:
+ print(f"stop at page {page}: {e.code}", file=sys.stderr)
+ break
+ events += batch
+ if len(batch) < 100:
+ break
+ items = [x for x in (item(e) for e in events) if x][:KEEP]
+ add_titles(items)
+ out = {"updated": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"), "items": items}
+ with open(out_path, "w", encoding="utf-8") as f:
+ json.dump(out, f, ensure_ascii=False, indent=1)
+ f.write("\n")
+ print(f"activity: {len(items)} items from {len(events)} events")
+
+
+if __name__ == "__main__":
+ main(sys.argv[1] if len(sys.argv) > 1 else "data/activity.json")