我只是想做一個自訂個人檔案。結果我把它徹底做過頭了。
不想讀?可以,沒關係。但你會錯過很多有趣的洞見。總之,這是我的個人檔案連結:
https://github.com/georgekobaidze
那是 23:00。我才剛結束極度疲憊的一天回到家,坐到筆電前的椅子上,然後……就只是盯著螢幕看。沒有計畫,沒有想法,什麼都沒有……一片空白。

我相信你一定懂這種感覺。這一天把你烤得太徹底,以至於大腦直接切到螢幕保護程式模式,隨機念頭開始自己飄進飄出,就像那些永遠差一點撞到角落的 DVD 標誌。
有趣的是:那正是我最好的點子常常冒出來的時候。當大腦停止硬撐,它就開始洗牌。隨機不一定代表不好。
那天晚上,在「我該去睡了」和「我真的該去睡了」之間,某個飄過來的念頭真的吸引了我的注意。天啊,那點子好到讓我瞬間恢復精神。真的瞬間。就像有人直接把我插進了牆上的插座一樣。
「你在開玩笑嗎?這也太酷了吧!我現在就要做。管我有多累。」
我就是這樣對自己說的。還是說出口的。23:00……請救我。
說真的,為什麼?那時候我已經清醒了大約 16 個小時,而我的大腦整整運轉了那麼久,就像戴通納或紐柏林 24 小時耐久賽裡的引擎一樣。順帶一提,我超喜歡那些賽道。
一般有理性的人早就把筆電關上了。
但我反而點了 VS Code 圖示,捲起袖子開始幹活。該開工了!🔥
我一直想讓我的貢獻圖不只是綠色方塊的格線而已。我想要某種更有原創性的東西。問題是,我一直都沒有想到夠好的點子。
而且不,我不想把它做成另一個吃豆人、貪食蛇或俄羅斯方塊遊戲。別誤會,這些都很有創意也很好玩,我很喜歡,也看過很多很棒的實作。但到了現在,這些點子到處都是,已經被用爛了。
我想要不一樣的。要能真正說出我是誰的東西。
我很喜歡《Cyberpunk 2077》以及它的氛圍。嗯,除了發售當天以外,當時車子會刷在彼此裡面,NPC 會莫名其妙在街上做出 T 字姿勢,不過那之後的一切?完全沒問題。

我喜歡有巨大摩天大樓的大城市。我喜歡離事情發生的地方很近,喜歡感受到節奏,以及跟上步調的壓力。反正我從來不是那種「我等不及退休去務農」的人。

噢,還有,我超愛霓虹燈。非常愛。
於是我就這樣一動也不動、面無表情地坐在螢幕前,然後那幅畫面突然在我腦中浮現:把我的貢獻圖立體化。每天一棟建築。安靜的日子是空地,忙碌的日子是摩天大樓。一整座城市。
就是這樣。貢獻之城開始成形了。

接著我想到:這樣的一座城市,不能只是孤零零地待在一個白底 README 上,旁邊再放一串徽章。它需要一個真正的家。
所以我決定更進一步,把整個個人檔案做成一個賽博龐克控制台:霓虹發光、掃描線、會打出我名字的終端機,以及一個把所有東西框在一起的外框。

因為為什麼不呢?
先來個現實檢查:GitHub 不讓你在 README 裡執行任何東西。<script> 會被移除。<style> 會被移除。甚至連內嵌的 style="" 屬性也會被移除。
我想要一個賽博龐克控制台。GitHub 給我的只有 Markdown 和少數幾個 HTML 標籤。
但問題在這裡。對 GitHub 來說,用 <img> 嵌入的 SVG 只是一張圖片。而在 SVG 裡面,你可以使用 CSS,包括動畫:
<svg xmlns="http://www.w3.org/2000/svg" width="300" height="60">
<style>
@keyframes blink { 50% { opacity: 0 } }
.cursor { animation: blink 1s step-end infinite }
</style>
<text x="10" y="38" fill="#3fb950">$ whoami</text>
<rect class="cursor" x="110" y="24" width="10" height="18" fill="#00d9ff"/>
</svg>
<img src="./assets/header.svg" width="100%">
這就是打字動畫、故障閃爍的名字,以及閃爍游標背後的全部技巧。我的個人檔案上每個「介面」元素,其實都偷偷是一張圖片。
圖片會以受限制的模式載入,這也帶來一些限制:
我的第一版把每個區塊都做成一個整齊的小面板:標頭一個框、關於文字一個框、統計資料一個框。每一個單獨看都很好看。
但放在一起,它們看起來像是被摔到地上的終端機。都是分開的零件,中間還有縫。
我想要的是一個控制台。一個可以從上到下連續捲動的視窗。
解法:先畫出一個大框,再切成水平切片。
把它們上下堆疊,側邊框就會對齊成一個長長的視窗:
<p align="center">
<img src="./assets/header.svg" width="100%" align="top">
<img src="./assets/stats.svg" width="100%" align="top">
<img src="./assets/contribution-city.svg" width="100%" align="top">
<!-- ...more slices... -->
<img src="./assets/footer.svg" width="100%" align="top">
</p>
專案卡片和連結按鈕也都是切片,只是更窄:半寬和五分之一寬的圖片,只畫外側那一邊的側邊框。把所有東西加總起來,就是 23 張假裝成一個終端機的圖片。
把圖片疊起來很簡單。要讓接縫看不見,則花了不少技巧:
align="top"。 瀏覽器預設會在圖片下方留一點空隙,那是為了像 g 和 y 這種會垂到基線下方的字母而保留的空間。對齊到頂端就能把它移除。這座城市不是新點子,它其實就是你已經熟悉的那張貢獻圖。53 欄的週、7 列的日。我只是把它轉成等角視角,並讓每個方塊有了高度。
每棟建築只由三個形狀組成:屋頂和兩面牆。關鍵在於繪製順序。建築會從後畫到前,所以離你比較近的建築自然會蓋住後面的建築。不需要 3D 引擎,只要畫家演算法和一個排序後的清單。
我知道,這問題聽起來很怪對吧?
我一開始的直覺很簡單:高度 = 貢獻數。結果做出來的是,最忙的一天變成一座超高塔,其他地方則是一片扁平的板子。這不是城市,比較像是中央放著帝國大廈的大型停車場。
所以我改用平方根來算高度:
{% katex
h = 8 + 110 \cdot \sqrt{\frac{c}{c_{\max}}}
{% endkatex
用 Python 來寫就是:
height = 8 + 110 * math.sqrt(count / busiest_day)
這樣能把貢獻少的日子也拉高,同時讓忙碌的日子依然明顯高出來:
| 貢獻數 | 線性 | 平方根 |
|---|---|---|
| 1 | 11 px | 25 px |
| 10 | 34 px | 61 px |
| 43(我的紀錄) | 118 px | 118 px |
沒有貢獻的日子就不會有建築。它們會保持空地狀態。我的城市有些月份看起來像是被荒廢的區域。你知道嗎?這其實很準確。
這是我的城市和實際 GitHub 活動圖的對比:
貢獻之城:

預設的 GitHub 貢獻圖:

夜晚的天際線需要燈光,所以每棟建築都有窗戶:有些亮著、有些關著,還有一些會閃爍。再加上閃爍的星星、月亮,以及一架燈光會閃的飛機飛過天空,這座城市就開始有了生命感。
窗戶是隨機排列的,但使用的是固定種子的隨機產生器。同樣的資料、同樣的種子、同一座城市,連最後一扇窗戶都一模一樣。為什麼這很重要?因為不然即使沒有新的貢獻,圖片也會每天變動一次,而我的 repo 就得每天做一次毫無意義的 commit。
GitHub 的貢獻圖是綠色的,所以我的第一版城市也用了綠色屋頂。大家一眼就會把它讀成「活動」,這很加分。
但我個人檔案的其他部分都是霓虹藍。和其他內容放在一起,綠色城市看起來像是從另一個網站貼過來的。所以我把兩個版本並排渲染,看了大概三秒,最後藍色勝出。我就是喜歡藍色。
這裡是兩個版本。你比較喜歡哪個?

一個統計資料從不變動的個人檔案,只是多了步驟的截圖。所以整個系統會在 GitHub 的伺服器上按排程執行,不管我的筆電有沒有開機。
整個流程有四步,其中三步是很小的 Python 腳本:
fetch.py → 向 GitHub 和 DEV 要最新數字,並存成 JSON
render.py → 讀取 JSON,重新繪製所有 SVG
readme.py → 更新 README.md 中會變動的部分
git commit → 只有真的有變更時才提交
把它們串起來的 workflow 長這樣:
name: Update profile
on:
schedule:
- cron: "17 12 * * *"
timezone: "America/New_York"
workflow_dispatch: # 給沒耐心的人(我)用的「執行工作流程」按鈕
permissions:
contents: write # 推送更新後檔案所需
jobs:
update:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/setup-python@v6
with:
python-version: "3.12"
- run: pip install fonttools==4.62.1 brotli==1.2.0
- run: python tools/profile/fetch.py
env:
PROFILE_TOKEN: ${{ secrets.PROFILE_TOKEN }}
GITHUB_TOKEN: ${{ github.token }}
DEV_API_KEY: ${{ secrets.DEV_API_KEY }}
- run: python tools/profile/render.py
- run: python tools/profile/readme.py
- run: |
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git add tools/profile/data assets README.md
git diff --cached --quiet || (git commit -m "Refresh profile stats" && git push)
最後那行比較有禮貌:git diff --cached --quiet 在沒有任何變更時會正常結束,所以只有真的有新東西時才會提交。沒有空的「更新」commit 來污染歷史紀錄。
GitHub 有兩種 API。REST 版的話,followers、pull requests、每個 repository、每個 repository 的語言……都要分開請求。我大概要一路分頁到下週二。
GraphQL API 讓我只要一次請求,就能精準要到我需要的資料:
query($login: String!) {
user(login: $login) {
createdAt
followers { totalCount }
pullRequests { totalCount }
merged: pullRequests(states: MERGED) { totalCount }
contributionsCollection { contributionYears }
repositories(ownerAffiliations: OWNER, isFork: false, privacy: PUBLIC, first: 100) {
nodes {
name
stargazerCount
forkCount
languages(first: 20) { edges { size node { name } } }
}
}
}
}
從這一次回應裡,我就拿到了 followers、PR 數量、加入日期、總 stars 與 forks(跨所有 repo 加總)、各專案卡片用的 star 數,以及語言長條圖(每種語言的程式碼位元組數加總)。
送出請求就是普通 Python,不需要 SDK:
def graphql(token, query, variables=None):
res = http_json("https://api.github.com/graphql",
headers={"Authorization": f"bearer {token}"},
body={"query": query, "variables": variables or {}})
if res.get("errors"):
raise RuntimeError("GraphQL error: " + "; ".join(e["message"] for e in res["errors"]))
return res["data"]
GitHub 有個怪癖:contributionsCollection 一次最多只涵蓋一年。你如果要求更多,它會很客氣地拒絕。
那要怎麼取得所有歷史貢獻,以及不會在 1 月 1 日斷掉的 streak?第一個查詢先回傳 contributionYears,也就是我曾活躍過的年份清單。接著我再動態組出第二個查詢,每一年都用一個別名欄位:
def years_query(years):
parts = []
for y in years:
parts.append(f"""
y{y}: contributionsCollection(from: "{y}-01-01T00:00:00Z", to: "{y}-12-31T23:59:59Z") {{
totalCommitContributions
contributionCalendar {{ totalContributions weeks {{ contributionDays {{ date contributionCount }} }} }}
}}""")
return "query($login: String!) {\n user(login: $login) {" + "".join(parts) + "\n }\n}"
y2016、y2017、……、y2026:一次往返就拿到十年的行事曆。GraphQL alias 的價值被嚴重低估了。
這樣我就能拿到自 2016 年以來每一天的 {date: count}。接著:
totalContributions 加總。def streaks(days, today):
longest = run = 0
for d in sorted(d for d in days if d <= today):
run = run + 1 if days[d] > 0 else 0
longest = max(longest, run)
current, d = 0, today
if days.get(d, 0) == 0: # 今天還沒有貢獻?
d -= datetime.timedelta(days=1) # 那 streak 仍然可能是昨天結束的
while days.get(d, 0) > 0:
current += 1
d -= datetime.timedelta(days=1)
return current, longest
那個小小的 if 比看起來重要得多。工作流程是在中午跑的。如果沒有它,我的 streak 每天都會在我當天第一次 commit 之前歸零。很有動力。只是方式不太對。
DEV 的數字來自 Forem API。文章、反應和留言都是公開的:
arts = dev_paged(f"https://dev.to/api/articles?username={USER}")
dev = {
"articles": len(arts),
"reactions": sum(a["public_reactions_count"] for a in arts),
"comments": sum(a["comments_count"] for a in arts),
}
瀏覽量和追蹤者是私有資料,所以需要把 API key 放進 api-key 標頭:
h = {"api-key": api_key, "Accept": "application/vnd.forem.api-v1+json"}
mine = dev_paged("https://dev.to/api/articles/me/published", h)
dev["views"] = sum(a["page_views_count"] for a in mine)
dev["followers"] = len(dev_paged("https://dev.to/api/followers/users", h))
dev_paged 只是一直請求 page=1, 2, 3...,直到回傳空陣列為止。相同的回應也會給我五篇最新文章,用在寫作區塊。
API 會失靈。速率限制、逾時、最糟糕時刻突然冒出一個 502。然後如果個人檔案因為 GitHub 一次小故障,就突然顯示「0 stars、0 contributions」,那比沒有統計更糟。
所以 fetch.py 不會從零開始。它會先載入昨天的 JSON,然後只覆寫自己成功抓到的部分:
stats = load("stats.json", {}) # 昨天的數字
try:
stats.update(fetch_github(token, today))
except Exception as ex:
warn(f"GitHub fetch failed, keeping previous stats: {ex}")
try:
dev, latest = fetch_dev(api_key)
...
except Exception as ex:
warn(f"DEV fetch failed, keeping previous values: {ex}")
如果 GitHub 掛了,DEV 的數字還是會更新;反之亦然。如果兩邊都失敗,腳本就會報錯並且什麼都不動。最糟也只是我的個人檔案晚一天更新,它永遠不會變成空白。
額外 бонус:JSON 檔也會和圖片一起提交,所以 git log -p tools/profile/data/stats.json 就能免費看到我的統計歷史。這不是我原本設計好的。不過我收下了。
README 大部分內容都不會變。但文章連結會變,描述統計資料給螢幕閱讀器看的 alt text 也會變。要是整份檔案都用模板重寫,就會把我手動改的內容全部覆蓋掉,所以 readme.py 只會碰明確標記的區塊:
<!-- writing:start -->
<a href="https://dev.to/..."><img src="./assets/writing/post-1.svg" ...></a>
...
<!-- writing:end -->
s, n = re.subn(r"<!-- writing:start -->.*?<!-- writing:end -->",
lambda _: writing_block(articles), s, flags=re.S)
if n != 1:
sys.exit("error: README needs exactly one writing block")
HTML 註解在 GitHub 上是看不見的,所以這些標記幾乎不會造成任何成本。而且如果有人(就是我)不小心刪掉其中一個,腳本會直接大聲報錯,而不是悄悄把 README 弄壞。
這個 workflow 需要兩個密鑰:一個 GitHub token,用來計算私有貢獻;還有 DEV API key。這兩個都不會寫進 repo。workflow 只會用名稱引用它們:
PROFILE_TOKEN: ${{ secrets.PROFILE_TOKEN }}
這些值會保存在 Settings → Secrets and variables → Actions 裡,經過加密。GitHub 會在執行時注入它們,如果它們真的出現在 log 裡,還會以 *** 遮蔽。
我為 token 用的是 fine-grained 個人存取 token,而不是 classic token,因為它可以是唯讀:Metadata 和 Contents,沒有別的權限。就算它洩漏了,最糟也只是別人能讀我的程式碼,不能推送進去。
而且這兩個都不是必需的。沒有它們時,workflow 會退回使用內建的 GITHUB_TOKEN 和公開資料。
用 :17 而不是 :00,因為大家都把工作排在整點,GitHub 的 runner 在 :00 會被塞爆。排在整點的排程常常會延後開始。選個看起來隨機的分鐘數,就能避開這波高峰。
來算算我這個「簡單自訂個人檔案」最後變成了什麼:
以上這些,只為了讓一個大多數人只會看八秒鐘的頁面,用霓虹字樣寫出我的名字。
絕對值得。
這其實不只是關於個人檔案。它關於那種 23:00 的感覺——某個隨機點子突然冒出來,然後你瞬間不累了。這種專案沒人要求,沒有期限,也沒有利害關係人,只因為好玩才做。
而且在這過程中,我學到了一堆如果不是這個專案,根本不會碰到的東西:SVG 動畫、等角繪圖、字型子集化、GraphQL alias,以及「我已經修好了」和「我看得出來我修好了」之間,到底隔了幾層快取。
更棒的是,這個個人檔案現在會自己維護。每天下午 12:17(紐約時間),一個機器人會醒來,查看我最近在做什麼,然後在我的城市裡新增一棟建築。即使在我覺得自己什麼都沒在做的日子也是如此。
這座城市的線上位置是:
https://github.com/georgekobaidze
而背後的所有程式碼都在同一個 repo 裡,位於 tools/profile。歡迎你去看看、fork 一份,然後打造你自己的天際線。只是先把使用者名稱改掉,除非你想把我的統計資料放到你的個人檔案上。
如果你用它做出了什麼酷東西,請在留言裡讓我知道。我很想看看你的城市長什麼樣子。
如果你不介意,我得去填一些安靜的日子了。我的城市裡還有幾塊空地,需要幾座摩天大樓。
$ exit
connection to georgekobaidze closed. // EOF
喜歡這篇文章嗎?讓我們保持聯繫!
我會在這些平台分享更多軟體工程的洞見、專案與實驗: