我只是想做一個自訂個人檔案。結果我把它徹底做過頭了。

不想讀?可以,沒關係。但你會錯過很多有趣的洞見。總之,這是我的個人檔案連結:

https://github.com/georgekobaidze


目錄


我想做一個簡單的 GitHub 自訂個人檔案。但事情很快就失控了

23:00。完全烤熟。

那是 23:00。我才剛結束極度疲憊的一天回到家,坐到筆電前的椅子上,然後……就只是盯著螢幕看。沒有計畫,沒有想法,什麼都沒有……一片空白。

Workspace

我相信你一定懂這種感覺。這一天把你烤得太徹底,以至於大腦直接切到螢幕保護程式模式,隨機念頭開始自己飄進飄出,就像那些永遠差一點撞到角落的 DVD 標誌。

隨機不代表不好

有趣的是:那正是我最好的點子常常冒出來的時候。當大腦停止硬撐,它就開始洗牌。隨機不一定代表不好。

那天晚上,在「我該去睡了」和「我真的該去睡了」之間,某個飄過來的念頭真的吸引了我的注意。天啊,那點子好到讓我瞬間恢復精神。真的瞬間。就像有人直接把我插進了牆上的插座一樣。

「你在開玩笑嗎?這也太酷了吧!我現在就要做。管我有多累。」

我就是這樣對自己說的。還是說出口的。23:00……請救我。

我為什麼老是這樣折磨自己?

說真的,為什麼?那時候我已經清醒了大約 16 個小時,而我的大腦整整運轉了那麼久,就像戴通納或紐柏林 24 小時耐久賽裡的引擎一樣。順帶一提,我超喜歡那些賽道。

一般有理性的人早就把筆電關上了。

但我反而點了 VS Code 圖示,捲起袖子開始幹活。該開工了!🔥


貢獻之城

代表我的那座城市

我一直想讓我的貢獻圖不只是綠色方塊的格線而已。我想要某種更有原創性的東西。問題是,我一直都沒有想到夠好的點子。

而且不,我不想把它做成另一個吃豆人、貪食蛇或俄羅斯方塊遊戲。別誤會,這些都很有創意也很好玩,我很喜歡,也看過很多很棒的實作。但到了現在,這些點子到處都是,已經被用爛了。

我想要不一樣的。要能真正說出我是誰的東西。

我很喜歡《Cyberpunk 2077》以及它的氛圍。嗯,除了發售當天以外,當時車子會刷在彼此裡面,NPC 會莫名其妙在街上做出 T 字姿勢,不過那之後的一切?完全沒問題。

V's Stash

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

Night City

噢,還有,我超愛霓虹燈。非常愛。

豁然開朗的那一刻

於是我就這樣一動也不動、面無表情地坐在螢幕前,然後那幅畫面突然在我腦中浮現:把我的貢獻圖立體化。每天一棟建築。安靜的日子是空地,忙碌的日子是摩天大樓。一整座城市。

就是這樣。貢獻之城開始成形了。

Contribution City


賽博龐克控制台

因為為什麼不呢?

接著我想到:這樣的一座城市,不能只是孤零零地待在一個白底 README 上,旁邊再放一串徽章。它需要一個真正的家。

所以我決定更進一步,把整個個人檔案做成一個賽博龐克控制台:霓虹發光、掃描線、會打出我名字的終端機,以及一個把所有東西框在一起的外框。

Cyberpunk Style Console

因為為什麼不呢?


歡迎來到沙盒

沒有 JavaScript。沒有 CSS。也沒有樂趣?

先來個現實檢查:GitHub 不讓你在 README 裡執行任何東西。<script> 會被移除。<style> 會被移除。甚至連內嵌的 style="" 屬性也會被移除。

我想要一個賽博龐克控制台。GitHub 給我的只有 Markdown 和少數幾個 HTML 標籤。

漏洞:SVG 只是一張圖片

但問題在這裡。對 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%">

這就是打字動畫、故障閃爍的名字,以及閃爍游標背後的全部技巧。我的個人檔案上每個「介面」元素,其實都偷偷是一張圖片。

好吧,但沙盒有規則

圖片會以受限制的模式載入,這也帶來一些限制:

  • 不能使用外部資源。 連 Google Fonts 都不行。所以字型會直接嵌入每一個 SVG 裡,並且只保留那張圖片實際用到的字元。
  • 不能 hover,也不能點擊。 圖片就是圖片。連結只能套用在整張圖片上,所以每張專案卡片和每個連結按鈕都必須是獨立檔案。
  • 快取。 GitHub 會快取圖片幾分鐘。我修了一個 bug,重新整理,看到了 bug。再修一次,重新整理,bug 還在。結果才發現,我第一次其實就已經修好了。

一個控制台,23 張圖片

問題:圖片不會牽著彼此的手

我的第一版把每個區塊都做成一個整齊的小面板:標頭一個框、關於文字一個框、統計資料一個框。每一個單獨看都很好看。

但放在一起,它們看起來像是被摔到地上的終端機。都是分開的零件,中間還有縫。

我想要的是一個控制台。一個可以從上到下連續捲動的視窗。

切割控制台

解法:先畫出一個大框,再切成水平切片。

  • 只有標頭會畫上邊框和標題列。
  • 只有頁尾會畫下邊框。
  • 中間每一個切片只畫左右兩條側邊框。

把它們上下堆疊,側邊框就會對齊成一個長長的視窗:

<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 張假裝成一個終端機的圖片。

讓它無縫銜接的技巧

把圖片疊起來很簡單。要讓接縫看不見,則花了不少技巧:

  • 40 px 格線。 每個切片的高度都是 40 像素的倍數,所以淡淡的背景格線會跨過切口連續延伸,不會突然跳掉。
  • 超出邊界的光暈。 側邊框上的霓虹光暈會從每個切片上方開始、在下方結束,因此不會像壞掉的日光燈管那樣在邊緣突然熄掉。
  • 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 活動圖的對比:

貢獻之城:

The Contribution City

預設的 GitHub 貢獻圖:

Default GH Graph

點亮燈光

夜晚的天際線需要燈光,所以每棟建築都有窗戶:有些亮著、有些關著,還有一些會閃爍。再加上閃爍的星星、月亮,以及一架燈光會閃的飛機飛過天空,這座城市就開始有了生命感。

窗戶是隨機排列的,但使用的是固定種子的隨機產生器。同樣的資料、同樣的種子、同一座城市,連最後一扇窗戶都一模一樣。為什麼這很重要?因為不然即使沒有新的貢獻,圖片也會每天變動一次,而我的 repo 就得每天做一次毫無意義的 commit。

綠色還是藍色?

GitHub 的貢獻圖是綠色的,所以我的第一版城市也用了綠色屋頂。大家一眼就會把它讀成「活動」,這很加分。

但我個人檔案的其他部分都是霓虹藍。和其他內容放在一起,綠色城市看起來像是從另一個網站貼過來的。所以我把兩個版本並排渲染,看了大概三秒,最後藍色勝出。我就是喜歡藍色。

這裡是兩個版本。你比較喜歡哪個?

Green vs. Blue


它每天都會自動更新

每天值班的機器人

一個統計資料從不變動的個人檔案,只是多了步驟的截圖。所以整個系統會在 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 對話:一次查詢,不用二十次

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 加總。
  • 城市 取最近 53 週,從週日開始,完全和 GitHub 自己的圖一致。
  • streak 則只是沿著日期一路走過去:
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 對話

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 心情不好時

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 會自己修改(而且很小心)

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 和公開資料。

為什麼是 12:17?

用 :17 而不是 :00,因為大家都把工作排在整點,GitHub 的 runner 在 :00 會被塞爆。排在整點的排程常常會延後開始。選個看起來隨機的分鐘數,就能避開這波高峰。


值得嗎?

損害報告

來算算我這個「簡單自訂個人檔案」最後變成了什麼:

  • 23 張假裝成一個終端機的 SVG 圖片
  • 一座以過去一年每一天為一棟建築的貢獻城市
  • 三個 Python 腳本和一個 GitHub Actions workflow
  • 兩個 API、兩把密鑰,以及一個非常勤奮的機器人

以上這些,只為了讓一個大多數人只會看八秒鐘的頁面,用霓虹字樣寫出我的名字。

所以……值得嗎?

絕對值得。

這其實不只是關於個人檔案。它關於那種 23:00 的感覺——某個隨機點子突然冒出來,然後你瞬間不累了。這種專案沒人要求,沒有期限,也沒有利害關係人,只因為好玩才做。

而且在這過程中,我學到了一堆如果不是這個專案,根本不會碰到的東西:SVG 動畫、等角繪圖、字型子集化、GraphQL alias,以及「我已經修好了」和「我看得出來我修好了」之間,到底隔了幾層快取。

更棒的是,這個個人檔案現在會自己維護。每天下午 12:17(紐約時間),一個機器人會醒來,查看我最近在做什麼,然後在我的城市裡新增一棟建築。即使在我覺得自己什麼都沒在做的日子也是如此。

來看看這個個人檔案

這座城市的線上位置是:

https://github.com/georgekobaidze

而背後的所有程式碼都在同一個 repo 裡,位於 tools/profile。歡迎你去看看、fork 一份,然後打造你自己的天際線。只是先把使用者名稱改掉,除非你想把我的統計資料放到你的個人檔案上。

如果你用它做出了什麼酷東西,請在留言裡讓我知道。我很想看看你的城市長什麼樣子。

如果你不介意,我得去填一些安靜的日子了。我的城市裡還有幾塊空地,需要幾座摩天大樓。

$ exit
connection to georgekobaidze closed. // EOF

喜歡這篇文章嗎?讓我們保持聯繫!

我會在這些平台分享更多軟體工程的洞見、專案與實驗:


原文出處:https://dev.to/georgekobaidze/i-turned-my-github-profile-into-a-cyberpunk-console-with-a-city-built-from-my-contributions-h4c


精選技術文章翻譯,幫助開發者持續吸收新知。

共有 0 則留言


精選技術文章翻譯,幫助開發者持續吸收新知。
🏆 本月排行榜
🥇
站長阿川
📝58   💬2  
764
🥈
NewsData
2
評分標準:發文×10 + 留言×3 + 獲讚×5 + 點讚×1 + 瀏覽數÷10
本數據每小時更新一次
📢 贊助商廣告 · 我要刊登