From 65473b68f8294fe5cf7b9bd504a8bd79ee3d1035 Mon Sep 17 00:00:00 2001 From: shawn Date: Mon, 12 Oct 2026 22:30:25 +0800 Subject: [PATCH] feat: add follow counts and mass-following hints to profile cards --- README.md | 2 +- README_CN.md | 2 +- docs/contributor-profile-card.md | 17 ++++-- src/_locales/en/messages.json | 18 +++++- src/_locales/zh_CN/messages.json | 18 +++++- src/_locales/zh_TW/messages.json | 18 +++++- src/features/contributor-card.test.ts | 83 +++++++++++++++++++++++++++ src/features/contributor-card.ts | 35 ++++++++++- src/lib/messages.ts | 1 + src/service-worker.test.ts | 28 +++++++++ src/service-worker.ts | 7 ++- src/styles/content.css | 4 ++ static/options.html | 6 +- 13 files changed, 223 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index 477f242..e0f9cc3 100644 --- a/README.md +++ b/README.md @@ -89,7 +89,7 @@ Better GitHub is a Chrome extension that enhances the GitHub experience. Inspire ### Profile -- **Contributor Card** — Hover a username to append a panel of objective facts to GitHub's native hovercard: account age, relation to the current repo (first-time vs. returning contributor), historical PR merge rate, and last-year activity. Helps you size up an unfamiliar contributor at a glance — for instance, spotting mass-generated low-effort PRs. Facts only: no scoring, no labels. The activity row requires a token. +- **Contributor Card** — Hover a username to append account age, follower/following counts, relation to the current repo (first-time vs. returning contributor), PR history, and last-year activity to GitHub's native hovercard. Following 20,000 or more users shows a possible mass-following hint based only on that count. The activity row requires a token.
Screenshot diff --git a/README_CN.md b/README_CN.md index 981f2b8..38b2a35 100644 --- a/README_CN.md +++ b/README_CN.md @@ -89,7 +89,7 @@ Better GitHub 是一个增强 GitHub 使用体验的 Chrome 插件,灵感源 ### 用户 -- **贡献者背景卡** — 悬停用户名时,在 GitHub 原生 hovercard 底部追加一块客观事实:账号年龄、与当前仓库的关系(首次贡献 / 回访贡献者)、历史 PR 合并率、近一年活跃度。帮你一眼判断一个陌生的贡献者——例如识别批量生成的低质量 PR。只摆事实,不打分、不贴标签。活跃度一行需要 token。 +- **贡献者背景卡** — 悬停用户名时,在 GitHub 原生 hovercard 底部追加账号年龄、粉丝与关注数量、与当前仓库的关系(首次贡献 / 回访贡献者)、历史 PR 和近一年活跃度。关注数达到 20,000 时显示疑似批量关注的提示,仅根据关注数量触发。活跃度一行需要 token。
示例截图 diff --git a/docs/contributor-profile-card.md b/docs/contributor-profile-card.md index e2d2d15..ff8ab17 100644 --- a/docs/contributor-profile-card.md +++ b/docs/contributor-profile-card.md @@ -1,14 +1,15 @@ # Contributor Profile Card Contributor Profile Card 在 GitHub 原生用户 hovercard 底部追加一组贡献者资料, -帮助用户快速了解不熟悉的贡献者。它只展示来自 GitHub 的客观信息,不打分、不贴 -标签,也不判断账号或贡献质量。 +帮助用户快速了解不熟悉的贡献者。资料来自 GitHub API,关注数量过高时追加 +疑似批量关注的提示。 ## 展示内容 | 项目 | 内容 | 数据来源 | | ---------- | ------------------------------------------------------------------ | ----------------------------------------------------------------------------------- | | 账号年龄 | 账号创建至今的时间及创建月份 | REST `GET /users/{login}` | +| 关注关系 | 粉丝数、关注数及关注数 / 粉丝数的倍数 | 同一用户资料请求的 `followers`、`following` | | 本仓库关系 | Owner、Member、Collaborator、Contributor 或 First-time contributor | REST `GET /repos/{owner}/{repo}/issues?creator={login}` 返回的 `author_association` | | 历史 PR | PR 总数、已合并数、关闭但未合并数 | Search API | | 活跃度 | 过去一年的贡献数 | GraphQL `contributionsCollection` | @@ -16,6 +17,14 @@ Contributor Profile Card 在 GitHub 原生用户 hovercard 底部追加一组贡 本仓库关系仅在仓库页面且 GitHub 返回有效关系时展示。历史 PR 总数为零或查询失败 时不展示该行。 +粉丝数和关注数合并在 Follow 行展示,大数字缩写为 `30k`、`100k` 等。悬停时 +显示关注数除以粉丝数的比例;没有粉丝时比例显示 `—`。 + +实际关注数达到 20,000 时,在同一行追加 `⚠`;粉丝数量和关注比例不参与触发。 +图标的悬停说明为“疑似批量关注,可能通过回关积累粉丝”,并解释触发条件。 +20,000 是经验阈值,不是 GitHub 官方判定标准。关注比例不代表回关率,数量提示 +也不能单独证明账号动机。关注关系复用已有用户资料请求,不额外获取粉丝或关注列表。 + 活跃度需要 GitHub token。未配置 token 时,卡片保留该行并提示用户连接 token; 其他公开资料仍可匿名获取,但受 GitHub API 匿名限额约束。 @@ -41,8 +50,8 @@ Contributor Profile Card 在 GitHub 原生用户 hovercard 底部追加一组贡 - 所有资料都直接从 GitHub API 获取,不发送给第三方服务。 - GitHub token 保存在 Chrome 本地存储中,只用于请求 GitHub API。 -- 功能不生成风险分数、可疑标签或颜色警告。 -- Followers 和 public repository 数量虽然随用户资料一起返回,但当前不会展示。 +- 关注提示只基于公开的关注数量,不生成风险分数。 +- Public repository 数量虽然随用户资料一起返回,但当前不会展示。 ## 代码位置 diff --git a/src/_locales/en/messages.json b/src/_locales/en/messages.json index 35f763e..cd9a5d1 100644 --- a/src/_locales/en/messages.json +++ b/src/_locales/en/messages.json @@ -69,9 +69,25 @@ }, "featContributorCardName": { "message": "Contributor Background Card" }, "featContributorCardDesc": { - "message": "Add objective facts (account age, relation to this repo, historical merge rate, activity) to the hovercard shown when you hover a username. Facts only — no scoring. Activity needs a token." + "message": "Add account age, follow counts, repo relationship, PR history, and activity to user hovercards. High following counts show a possible mass-following hint. Activity needs a token." }, "ccAge": { "message": "Account age" }, + "ccFollow": { "message": "Follow" }, + "ccMassFollowingHelp": { + "message": "Possible mass-following to gain followers through follow-backs. Based only on following count (20,000 or more)." + }, + "ccFollowersCount": { + "message": "$count$ followers", + "placeholders": { "count": { "content": "$1", "example": "91" } } + }, + "ccFollowingCount": { + "message": "$count$ following", + "placeholders": { "count": { "content": "$1", "example": "7" } } + }, + "ccFollowRatioHelp": { + "message": "Following ÷ followers: $ratio$. This is not a follow-back rate. — means the account has no followers.", + "placeholders": { "ratio": { "content": "$1", "example": "0.08×" } } + }, "ccRepo": { "message": "This repo" }, "ccHistory": { "message": "PR history" }, "ccActivity": { "message": "Activity" }, diff --git a/src/_locales/zh_CN/messages.json b/src/_locales/zh_CN/messages.json index 7ce0bd0..fb5dd99 100644 --- a/src/_locales/zh_CN/messages.json +++ b/src/_locales/zh_CN/messages.json @@ -69,9 +69,25 @@ }, "featContributorCardName": { "message": "贡献者背景卡" }, "featContributorCardDesc": { - "message": "在 hover 用户名弹出的卡片里追加客观事实(账号年龄、与本仓库关系、历史 merge 率、活跃度),帮你判断账号。只摆事实、不打分。活跃度需要令牌。" + "message": "在用户名悬停卡片中展示账号年龄、关注关系、本仓库关系、历史 PR 和活跃度。关注数量过高时提示疑似批量关注。活跃度需要令牌。" }, "ccAge": { "message": "账号年龄" }, + "ccFollow": { "message": "关注" }, + "ccMassFollowingHelp": { + "message": "疑似批量关注,可能通过回关积累粉丝。仅根据关注数量达到 20,000 的情况提示。" + }, + "ccFollowersCount": { + "message": "$count$ 粉丝", + "placeholders": { "count": { "content": "$1", "example": "91" } } + }, + "ccFollowingCount": { + "message": "关注 $count$ 人", + "placeholders": { "count": { "content": "$1", "example": "7" } } + }, + "ccFollowRatioHelp": { + "message": "关注数 ÷ 粉丝数:$ratio$,不代表回关率。— 表示该账号没有粉丝。", + "placeholders": { "ratio": { "content": "$1", "example": "0.08×" } } + }, "ccRepo": { "message": "本仓库" }, "ccHistory": { "message": "历史 PR" }, "ccActivity": { "message": "活跃度" }, diff --git a/src/_locales/zh_TW/messages.json b/src/_locales/zh_TW/messages.json index 62dee3e..8b763d8 100644 --- a/src/_locales/zh_TW/messages.json +++ b/src/_locales/zh_TW/messages.json @@ -69,9 +69,25 @@ }, "featContributorCardName": { "message": "貢獻者背景卡" }, "featContributorCardDesc": { - "message": "在 hover 使用者名稱彈出的卡片裡追加客觀事實(帳號年齡、與本儲存庫關係、歷史 merge 率、活躍度),幫你判斷帳號。只擺事實、不打分。活躍度需要權杖。" + "message": "在使用者名稱懸停卡片中展示帳號年齡、關注關係、本儲存庫關係、歷史 PR 和活躍度。關注數量過高時提示疑似批量關注。活躍度需要權杖。" }, "ccAge": { "message": "帳號年齡" }, + "ccFollow": { "message": "關注" }, + "ccMassFollowingHelp": { + "message": "疑似批量關注,可能透過回關累積粉絲。僅根據關注數量達到 20,000 的情況提示。" + }, + "ccFollowersCount": { + "message": "$count$ 粉絲", + "placeholders": { "count": { "content": "$1", "example": "91" } } + }, + "ccFollowingCount": { + "message": "關注 $count$ 人", + "placeholders": { "count": { "content": "$1", "example": "7" } } + }, + "ccFollowRatioHelp": { + "message": "關注數 ÷ 粉絲數:$ratio$,不代表回關率。— 表示該帳號沒有粉絲。", + "placeholders": { "ratio": { "content": "$1", "example": "0.08×" } } + }, "ccRepo": { "message": "本儲存庫" }, "ccHistory": { "message": "歷史 PR" }, "ccActivity": { "message": "活躍度" }, diff --git a/src/features/contributor-card.test.ts b/src/features/contributor-card.test.ts index a040bc0..7c21125 100644 --- a/src/features/contributor-card.test.ts +++ b/src/features/contributor-card.test.ts @@ -1,6 +1,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { setUrl } from "../test-utils/url"; import type { ContributorInfo } from "../lib/messages"; +import { setLocale } from "../lib/i18n"; // The card fetches via the service-worker bridge; stub it so tests are pure DOM. const fetchContributorInfo = @@ -21,6 +22,7 @@ function baseInfo(over: Partial = {}): ContributorInfo { login: "octocat", createdAt: "2026-06-13T00:00:00Z", // 3 days before NOW followers: 10, + following: 5, publicRepos: 5, prTotal: 8, prMerged: 2, @@ -56,6 +58,7 @@ describe("injectContributorCard", () => { setUrl(`${GH}/owner/repo/pull/1`); document.body.innerHTML = ""; fetchContributorInfo.mockReset(); + setLocale("en"); }); afterEach(() => { @@ -80,6 +83,78 @@ describe("injectContributorCard", () => { expect(text).toContain("1240 contributions in the past year"); // token activity }); + it.each([ + [ + "en", + "Follow", + "30k followers · 100k following ⚠", + "This is not a follow-back rate", + "Possible mass-following", + ], + ["zh_CN", "关注", "30k 粉丝 · 关注 100k 人 ⚠", "不代表回关率", "疑似批量关注"], + ["zh_TW", "關注", "30k 粉絲 · 關注 100k 人 ⚠", "不代表回關率", "疑似批量關注"], + ] as const)( + "shows follower and following counts in one row in %s", + async (locale, label, counts, help, warningText) => { + setLocale(locale); + fetchContributorInfo.mockResolvedValue(baseInfo({ followers: 30000, following: 100000 })); + injectContributorCard(); + document.body.appendChild(hovercard("octocat")); + await flush(); + + const rows = [...document.querySelectorAll(`${BLOCK}-row`)]; + const connections = rows.find((r) => r.textContent?.startsWith(label)); + expect(connections?.querySelector(`${BLOCK}-label`)?.textContent).toBe(label); + expect(connections?.querySelector(`${BLOCK}-value`)?.textContent).toBe(counts); + expect(connections?.title).toContain("3.33×"); + expect(connections?.title).toContain(help); + const warning = connections?.querySelector(`${BLOCK}-warning`); + expect(warning?.title).toContain(warningText); + expect(warning?.getAttribute("aria-label")).toBe(warning?.title); + expect(rows).toHaveLength(5); + }, + ); + + it.each([ + [0, 100, "—"], + [0, 0, "—"], + [10, 0, "0×"], + [10, 5, "0.5×"], + [92, 7, "0.08×"], + ])("handles %i followers and %i following", async (followers, following, ratio) => { + fetchContributorInfo.mockResolvedValue(baseInfo({ followers, following })); + injectContributorCard(); + document.body.appendChild(hovercard("octocat")); + await flush(); + + const connections = [...document.querySelectorAll(`${BLOCK}-row`)].find((r) => + r.textContent?.startsWith("Follow"), + ); + expect(connections?.textContent).toBe(`Follow${followers} followers · ${following} following`); + expect(connections?.title).toContain(`Following ÷ followers: ${ratio}.`); + }); + + it.each([ + [30000, 19999, "30k followers · 20k following", false], + [30000, 20000, "30k followers · 20k following ⚠", true], + [0, 20000, "0 followers · 20k following ⚠", true], + [1000000, 7, "1m followers · 7 following", false], + ])( + "checks the actual following count for %i followers and %i following", + async (followers, following, counts, flagged) => { + fetchContributorInfo.mockResolvedValue(baseInfo({ followers, following })); + injectContributorCard(); + document.body.appendChild(hovercard("octocat")); + await flush(); + + const connections = [...document.querySelectorAll(`${BLOCK}-row`)].find((r) => + r.textContent?.startsWith("Follow"), + ); + expect(connections?.querySelector(`${BLOCK}-value`)?.textContent).toBe(counts); + expect(connections?.querySelector(`${BLOCK}-warning`) != null).toBe(flagged); + }, + ); + it("passes the current repo context to the fetch", async () => { fetchContributorInfo.mockResolvedValue(baseInfo()); injectContributorCard(); @@ -305,6 +380,11 @@ describe("injectContributorCard", () => { expect(loading!.dataset.skeleton).toBe("true"); // a skeleton while pending expect(loading!.querySelector(`${BLOCK}-skeleton`)).not.toBeNull(); expect(loading!.textContent).not.toContain("days"); // no real data yet + const loadingLabels = [...loading!.querySelectorAll(`${BLOCK}-label`)].map( + (el) => el.textContent, + ); + expect(loadingLabels).toContain("Follow"); + expect(loadingLabels).toHaveLength(5); resolve(baseInfo()); await flush(); @@ -313,6 +393,9 @@ describe("injectContributorCard", () => { expect(panels).toHaveLength(1); // skeleton replaced in place, not duplicated expect(panels[0].dataset.skeleton).toBeUndefined(); expect(panels[0].textContent).toContain("3 days · 2026-06"); // real data + expect([...panels[0].querySelectorAll(`${BLOCK}-label`)].map((el) => el.textContent)).toEqual( + loadingLabels, + ); }); it("does not double-decorate the same populated card", async () => { diff --git a/src/features/contributor-card.ts b/src/features/contributor-card.ts index 235a699..bb6dcb3 100644 --- a/src/features/contributor-card.ts +++ b/src/features/contributor-card.ts @@ -2,7 +2,8 @@ // // GitHub shows a hovercard when you hover a username. We attach a panel of // *objective facts* to it — account age, relation to this repo, historical merge -// history, and activity. Facts only: no score, labels, or warnings. +// history, activity, and follower/following counts. A high following count gets +// a possible mass-following hint. // See docs/contributor-profile-card.md. // // DOM verified against a live page (2026-06): @@ -28,6 +29,11 @@ import type { ContributorInfo } from "../lib/messages"; import { t } from "../lib/i18n"; const PANEL_CLASS = "better-github-contributor-card"; +const FOLLOW_COUNT_FORMAT = new Intl.NumberFormat("en", { + notation: "compact", + maximumFractionDigits: 1, +}); +const MASS_FOLLOWING_THRESHOLD = 20_000; /** Read the login from a hovercard content root's hydro-view payload, but only * when it's a *user* hovercard (repos/issues reuse the same container). */ @@ -134,6 +140,30 @@ function buildPanel(login: string, info: ContributorInfo): HTMLElement { row("ccAge", valueEl([{ strong: `${age.value} ${ageUnitWord(age)}` }, { dim: ` · ${created}` }])), ); + const followers = FOLLOW_COUNT_FORMAT.format(info.followers).toLowerCase(); + const following = FOLLOW_COUNT_FORMAT.format(info.following).toLowerCase(); + const followCounts = valueEl([ + ...emphasizeToken(t("ccFollowersCount", followers), followers), + " · ", + ...emphasizeToken(t("ccFollowingCount", following), following), + ]); + const connections = row("ccFollow", followCounts); + const ratio = + info.followers === 0 + ? "—" + : `${(info.following / info.followers).toLocaleString(undefined, { maximumFractionDigits: 2 })}×`; + connections.title = t("ccFollowRatioHelp", ratio); + if (info.following >= MASS_FOLLOWING_THRESHOLD) { + const warning = document.createElement("span"); + warning.className = `${PANEL_CLASS}-warning`; + warning.textContent = "⚠"; + warning.title = t("ccMassFollowingHelp"); + warning.setAttribute("role", "img"); + warning.setAttribute("aria-label", warning.title); + followCounts.append(" ", warning); + } + panel.appendChild(connections); + const rel = repoRelation(info.repoAssociation); if (rel) { panel.appendChild(row("ccRepo", valueEl([{ strong: t(relationLabelKey(rel)) }]))); @@ -189,9 +219,10 @@ function buildSkeleton(login: string): HTMLElement { panel.dataset.login = login; panel.dataset.skeleton = "true"; panel.appendChild(header()); - // [label key, value-bar width] per row — mirrors age/repo/history/activity. + // [label key, value-bar width] per row — mirrors the loaded facts. for (const [labelKey, vw] of [ ["ccAge", 92], + ["ccFollow", 132], ["ccRepo", 132], ["ccHistory", 108], ["ccActivity", 150], diff --git a/src/lib/messages.ts b/src/lib/messages.ts index 316485f..79ff3ac 100644 --- a/src/lib/messages.ts +++ b/src/lib/messages.ts @@ -90,6 +90,7 @@ export interface ContributorInfo { /** ISO timestamp of account creation (REST `created_at`). */ createdAt: string; followers: number; + following: number; publicRepos: number; /** Total PRs authored across GitHub (Search `type:pr author:X`). */ prTotal: number; diff --git a/src/service-worker.test.ts b/src/service-worker.test.ts index d4a0e28..65dc82b 100644 --- a/src/service-worker.test.ts +++ b/src/service-worker.test.ts @@ -129,6 +129,34 @@ describe("service worker", () => { vi.restoreAllMocks(); }); + it("returns follower and following counts from the cached user profile request", async () => { + const state = await loadWorker(); + const fetchMock = vi.mocked(fetch); + fetchMock.mockImplementation(async (input) => { + if (String(input) === "https://api.github.com/users/octocat") { + return jsonResponse({ + created_at: "2020-01-01T00:00:00Z", + followers: 30000, + following: 100000, + public_repos: 5, + }); + } + return jsonResponse({ total_count: 0 }); + }); + const request: ServiceWorkerRequest = { type: "FETCH_CONTRIBUTOR_INFO", login: "octocat" }; + + const first = await sendMessage(state.messageListeners[0], request); + const second = await sendMessage(state.messageListeners[0], request); + + expect(first).toMatchObject({ + ok: true, + data: { followers: 30000, following: 100000, hasToken: false }, + }); + expect(second).toEqual(first); + expect(fetchMock).toHaveBeenCalledTimes(4); // one user profile and three existing PR searches + expect(fetchMock.mock.calls.filter(([url]) => String(url).includes("/users/"))).toHaveLength(1); + }); + it("coalesces concurrent requests and caches successful PR branch responses", async () => { const state = await loadWorker(); const fetchDeferred = deferred(); diff --git a/src/service-worker.ts b/src/service-worker.ts index 4ae9f72..f2fc6a1 100644 --- a/src/service-worker.ts +++ b/src/service-worker.ts @@ -802,21 +802,23 @@ async function searchCount(query: string, token: string): Promise { interface UserProfile { createdAt: string; followers: number; + following: number; publicRepos: number; } async function fetchUserProfile(login: string, token: string): Promise { - return cachedFetch(`cache:contrib:user:${login}`, async () => { + return cachedFetch(`cache:contrib:user:v2:${login}`, async () => { const response = await fetch(`https://api.github.com/users/${encodeURIComponent(login)}`, { headers: restHeaders(token), }); if (!response.ok) return null; - const data: { created_at?: string; followers?: number; public_repos?: number } = + const data: { created_at?: string; followers?: number; following: number; public_repos?: number } = await response.json(); if (!data.created_at) return null; return { createdAt: data.created_at, followers: data.followers ?? 0, + following: data.following, publicRepos: data.public_repos ?? 0, }; }); @@ -902,6 +904,7 @@ async function fetchContributorInfo( login, createdAt: profile.createdAt, followers: profile.followers, + following: profile.following, publicRepos: profile.publicRepos, prTotal, prMerged, diff --git a/src/styles/content.css b/src/styles/content.css index 5b6ae37..38202b8 100644 --- a/src/styles/content.css +++ b/src/styles/content.css @@ -967,6 +967,10 @@ a.better-github-review-popover-item:hover { color: var(--fgColor-muted, var(--color-fg-muted, #59636e)); } +.better-github-contributor-card-warning { + color: var(--fgColor-attention, var(--color-attention-fg, #9a6700)); +} + /* Loading skeleton — shown the instant the card opens so real data fills in rather than the panel popping into existence. Bars sit in the same row slots as the real label/value, so swapping in the data causes no layout shift. diff --git a/static/options.html b/static/options.html index e2b0547..c9bd0bc 100644 --- a/static/options.html +++ b/static/options.html @@ -760,9 +760,9 @@

Settings

Contributor Background Card
- Add objective facts (account age, relation to this repo, historical merge rate, - activity) to the hovercard shown when you hover a username. Facts only — no scoring. - Activity needs a token. + Add account age, follow counts, repo relationship, PR history, and activity to user + hovercards. High following counts show a possible mass-following hint. Activity needs + a token.