feat(dashboard): add social account lookup for stargazers

- Add multi-source social search (Keybase, about.me, Linktree, GitHub bio/blog)
- Implement avatar comparison and cross-platform verification
- Create GitHub Action for scheduled cache updates (2h incremental, weekly full)
- Display Mastodon, Bluesky, Twitter links in stargazer popover
- Add smooth hover animations with invisible bridge for better UX
- Add social-overrides.json for manual account mappings
This commit is contained in:
phranck
2026-02-04 20:09:55 +01:00
parent 304860ba37
commit c78d439c4d
10 changed files with 1843 additions and 29 deletions
+58
View File
@@ -0,0 +1,58 @@
name: Update Social Cache
on:
schedule:
- cron: '0 */2 * * *' # Every 2 hours (incremental)
- cron: '0 4 * * 0' # Weekly full refresh (Sundays 4 AM UTC)
workflow_dispatch:
inputs:
full_refresh:
description: 'Run full refresh instead of incremental'
required: false
default: 'false'
type: boolean
jobs:
update:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '22'
cache: 'npm'
cache-dependency-path: docs/package-lock.json
- name: Install dependencies
run: npm ci
working-directory: docs
- name: Determine if full refresh
id: mode
run: |
# Full refresh on Sundays (cron) or if manually triggered with full_refresh=true
if [[ "${{ github.event.schedule }}" == "0 4 * * 0" ]] || [[ "${{ inputs.full_refresh }}" == "true" ]]; then
echo "args=--full" >> "$GITHUB_OUTPUT"
echo "Running FULL refresh"
else
echo "args=" >> "$GITHUB_OUTPUT"
echo "Running incremental update"
fi
- name: Update social cache
run: npx tsx scripts/update-social-cache.ts ${{ steps.mode.outputs.args }}
working-directory: docs
env:
GITHUB_TOKEN: ${{ secrets.DASHBOARD_GITHUB_TOKEN }}
- name: Commit if changed
run: |
git diff --quiet docs/public/social-cache.json && echo "No changes" && exit 0
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add docs/public/social-cache.json
git commit -m "chore: update social cache [skip ci]"
git push
+37 -8
View File
@@ -15,6 +15,10 @@ interface HoverPopoverProps {
minWidth?: string;
/** Content rendered inside the popover bubble. */
children: ReactNode;
/** Called when mouse enters the popover (to cancel hide). */
onMouseEnter?: () => void;
/** Called when mouse leaves the popover. */
onMouseLeave?: () => void;
}
/**
@@ -23,24 +27,49 @@ interface HoverPopoverProps {
* Must be placed inside a `position: relative` container.
* Centers horizontally on `x` and positions above `y` by default.
*/
export default function HoverPopover({ visible, x, y, offsetY = -10, minWidth, children }: HoverPopoverProps) {
export default function HoverPopover({
visible,
x,
y,
offsetY = -10,
minWidth,
children,
onMouseEnter,
onMouseLeave,
}: HoverPopoverProps) {
return (
<div
className="pointer-events-none absolute z-20 rounded-lg border border-border px-4 py-2 shadow-lg shadow-black/30 transition-opacity duration-150"
className="absolute z-20"
style={{
left: x,
top: y + offsetY,
transform: "translateX(-50%) translateY(-100%)",
minWidth,
backgroundColor: "var(--container-body)",
opacity: visible ? 1 : 0,
pointerEvents: visible ? "auto" : "none",
}}
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
>
{children}
{/* Invisible bridge area to help mouse travel from avatar to popover */}
<div
className="absolute left-1/2 -bottom-[7px] h-3 w-3 -translate-x-1/2 rotate-45 border-b border-r border-border"
style={{ backgroundColor: "var(--container-body)" }}
className="absolute left-1/2 -translate-x-1/2 w-16"
style={{ height: Math.abs(offsetY) + 20, bottom: -Math.abs(offsetY) - 10 }}
/>
<div
className="rounded-lg border border-border px-4 py-2 shadow-lg shadow-black/30"
style={{
minWidth,
backgroundColor: "var(--container-body)",
opacity: visible ? 1 : 0,
transform: visible ? "translateY(0)" : "translateY(15%)",
transition: "opacity 200ms ease-out, transform 200ms ease-out",
}}
>
{children}
<div
className="absolute left-1/2 -bottom-[7px] h-3 w-3 -translate-x-1/2 rotate-45 border-b border-r border-border"
style={{ backgroundColor: "var(--container-body)" }}
/>
</div>
</div>
);
}
+36
View File
@@ -81,6 +81,42 @@ const customIcons = {
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
</svg>
),
/** Mastodon logo. */
mastodon: (size: number) => (
<svg
xmlns="http://www.w3.org/2000/svg"
width={size}
height={size}
viewBox="0 0 24 24"
fill="currentColor"
>
<path d="M23.268 5.313c-.35-2.578-2.617-4.61-5.304-5.004C17.51.242 15.792 0 11.813 0h-.03c-3.98 0-4.835.242-5.288.309C3.882.692 1.496 2.518.917 5.127.64 6.412.61 7.837.661 9.143c.074 1.874.088 3.745.26 5.611.118 1.24.325 2.47.62 3.68.55 2.237 2.777 4.098 4.96 4.857 2.336.792 4.849.923 7.256.38.265-.061.527-.132.786-.213.585-.184 1.27-.39 1.774-.753a.057.057 0 0 0 .023-.043v-1.809a.052.052 0 0 0-.02-.041.053.053 0 0 0-.046-.01 20.282 20.282 0 0 1-4.709.545c-2.73 0-3.463-1.284-3.674-1.818a5.593 5.593 0 0 1-.319-1.433.053.053 0 0 1 .066-.054c1.517.363 3.072.546 4.632.546.376 0 .75 0 1.125-.01 1.57-.044 3.224-.124 4.768-.422.038-.008.077-.015.11-.024 2.435-.464 4.753-1.92 4.989-5.604.008-.145.03-1.52.03-1.67.002-.512.167-3.63-.024-5.545zm-3.748 9.195h-2.561V8.29c0-1.309-.55-1.976-1.67-1.976-1.23 0-1.846.79-1.846 2.35v3.403h-2.546V8.663c0-1.56-.617-2.35-1.848-2.35-1.112 0-1.668.668-1.668 1.977v6.218H4.822V8.102c0-1.31.337-2.35 1.011-3.12.696-.77 1.608-1.164 2.74-1.164 1.311 0 2.302.5 2.962 1.498l.638 1.06.638-1.06c.66-.999 1.65-1.498 2.96-1.498 1.13 0 2.043.395 2.74 1.164.675.77 1.012 1.81 1.012 3.12z" />
</svg>
),
/** Twitter/X logo. */
twitter: (size: number) => (
<svg
xmlns="http://www.w3.org/2000/svg"
width={size}
height={size}
viewBox="0 0 24 24"
fill="currentColor"
>
<path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z" />
</svg>
),
/** Bluesky logo. */
bluesky: (size: number) => (
<svg
xmlns="http://www.w3.org/2000/svg"
width={size}
height={size}
viewBox="0 0 24 24"
fill="currentColor"
>
<path d="M12 10.8c-1.087-2.114-4.046-6.053-6.798-7.995C2.566.944 1.561 1.266.902 1.565.139 1.908 0 3.08 0 3.768c0 .69.378 5.65.624 6.479.815 2.736 3.713 3.66 6.383 3.364.136-.02.275-.039.415-.056-.138.022-.276.04-.415.056-3.912.58-7.387 2.005-2.83 7.078 5.013 5.19 6.87-1.113 7.823-4.308.953 3.195 2.05 9.271 7.733 4.308 4.267-4.308 1.172-6.498-2.74-7.078a8.741 8.741 0 0 1-.415-.056c.14.017.279.036.415.056 2.67.297 5.568-.628 6.383-3.364.246-.828.624-5.79.624-6.478 0-.69-.139-1.861-.902-2.206-.659-.298-1.664-.62-4.3 1.24C16.046 4.748 13.087 8.687 12 10.8z" />
</svg>
),
} as const;
export type IconName = keyof typeof sfIcons | keyof typeof customIcons;
+59 -11
View File
@@ -29,7 +29,7 @@ export default function StargazersPanel({ stargazers, totalStars, open }: Starga
const contentRef = useRef<HTMLDivElement>(null);
const wrapperRef = useRef<HTMLDivElement>(null);
const gridRef = useRef<HTMLDivElement>(null);
const { hover, popover, show: showPopover, hide: hidePopover } = useHoverPopover<string>();
const { hover, popover, show: showPopover, hide: hidePopover, cancelHide } = useHoverPopover<Stargazer>();
useEffect(() => {
const wrapper = wrapperRef.current;
@@ -52,7 +52,7 @@ export default function StargazersPanel({ stargazers, totalStars, open }: Starga
}
}, [open]);
function handleMouseEnter(event: React.MouseEvent<HTMLAnchorElement>, login: string) {
function handleMouseEnter(event: React.MouseEvent<HTMLAnchorElement>, user: Stargazer) {
const target = event.currentTarget;
const grid = gridRef.current;
if (!grid) return;
@@ -61,7 +61,7 @@ export default function StargazersPanel({ stargazers, totalStars, open }: Starga
const targetRect = target.getBoundingClientRect();
showPopover(
login,
user,
targetRect.left - gridRect.left + targetRect.width / 2,
targetRect.top - gridRect.top,
);
@@ -70,8 +70,8 @@ export default function StargazersPanel({ stargazers, totalStars, open }: Starga
return (
<div
ref={wrapperRef}
className="overflow-hidden transition-[height,opacity] duration-300 ease-in-out"
style={{ height: 0, opacity: open ? 1 : 0 }}
className="transition-[height,opacity] duration-300 ease-in-out"
style={{ height: 0, opacity: open ? 1 : 0, overflow: open ? "visible" : "hidden" }}
>
<div ref={contentRef} className="rounded-xl border border-border bg-frosted-glass p-6 backdrop-blur-xl">
<h3 className="mb-4 flex items-center gap-2 text-lg font-semibold text-foreground">
@@ -94,8 +94,8 @@ export default function StargazersPanel({ stargazers, totalStars, open }: Starga
href={user.profileUrl}
target="_blank"
rel="noopener noreferrer"
className="group flex aspect-square items-center justify-center rounded-2xl p-4 transition-colors hover:bg-accent/10"
onMouseEnter={(event) => handleMouseEnter(event, user.login)}
className="group flex aspect-square items-center justify-center rounded-2xl p-4 transition-all duration-200 ease-out hover:bg-accent/10"
onMouseEnter={(event) => handleMouseEnter(event, user)}
onMouseLeave={hidePopover}
>
<img
@@ -103,7 +103,7 @@ export default function StargazersPanel({ stargazers, totalStars, open }: Starga
alt={user.login}
width={100}
height={100}
className="avatar-tinted rounded-full ring-1 ring-border transition-all duration-300 group-hover:ring-accent/50 group-hover:scale-110"
className="avatar-tinted rounded-full ring-1 ring-border transition-all duration-200 ease-out group-hover:ring-2 group-hover:ring-accent/60 group-hover:scale-110 group-hover:-translate-y-1"
loading={index < EAGER_AVATAR_COUNT ? "eager" : "lazy"}
fetchPriority={index < EAGER_AVATAR_COUNT ? "high" : "auto"}
/>
@@ -115,10 +115,58 @@ export default function StargazersPanel({ stargazers, totalStars, open }: Starga
visible={!!hover}
x={popover?.x ?? 0}
y={popover?.y ?? 0}
minWidth="140px"
onMouseEnter={cancelHide}
onMouseLeave={hidePopover}
>
<p className="whitespace-nowrap text-center text-sm font-medium text-foreground">
{popover?.data}
</p>
<div className="flex flex-col items-center gap-1.5">
<p className="whitespace-nowrap text-center text-sm font-medium text-foreground">
{popover?.data?.login}
</p>
{(popover?.data?.mastodon || popover?.data?.twitter || popover?.data?.bluesky) && (
<div className="flex flex-col items-start gap-1">
{popover?.data?.mastodon && (
<a
href={popover.data.mastodon.url}
target="_blank"
rel="noopener noreferrer"
className="pointer-events-auto flex items-center gap-1.5 text-muted hover:text-accent transition-colors text-xs"
onClick={(e) => e.stopPropagation()}
title={popover.data.mastodon.handle}
>
<Icon name="mastodon" size={14} />
<span>Mastodon</span>
</a>
)}
{popover?.data?.bluesky && (
<a
href={popover.data.bluesky.url}
target="_blank"
rel="noopener noreferrer"
className="pointer-events-auto flex items-center gap-1.5 text-muted hover:text-accent transition-colors text-xs"
onClick={(e) => e.stopPropagation()}
title={popover.data.bluesky.handle}
>
<Icon name="bluesky" size={14} />
<span>Bluesky</span>
</a>
)}
{popover?.data?.twitter && (
<a
href={popover.data.twitter.url}
target="_blank"
rel="noopener noreferrer"
className="pointer-events-auto flex items-center gap-1.5 text-muted hover:text-accent transition-colors text-xs"
onClick={(e) => e.stopPropagation()}
title={popover.data.twitter.handle}
>
<Icon name="twitter" size={14} />
<span>Twitter</span>
</a>
)}
</div>
)}
</div>
</HoverPopover>
</div>
)}
+70 -5
View File
@@ -28,6 +28,48 @@ export interface Stargazer {
login: string;
avatarUrl: string;
profileUrl: string;
mastodon?: {
handle: string;
url: string;
};
twitter?: {
handle: string;
url: string;
};
bluesky?: {
handle: string;
url: string;
};
}
/** Social account cache entry from the pre-generated JSON. */
interface SocialCacheEntry {
login: string;
mastodon?: {
handle: string;
url: string;
source: string;
verified: boolean;
};
twitter?: {
handle: string;
url: string;
source: string;
verified: boolean;
};
bluesky?: {
handle: string;
url: string;
source: string;
verified: boolean;
};
updatedAt: string;
}
/** Structure of the social-cache.json file. */
interface SocialCache {
generatedAt: string | null;
entries: Record<string, SocialCacheEntry>;
}
/** Weekly commit activity (52 weeks). */
@@ -299,6 +341,17 @@ export function useGitHubStats(): UseGitHubStatsReturn {
/* search API can be rate-limited separately */
}
// Fetch social cache to merge with stargazers
let socialCache: SocialCache = { generatedAt: null, entries: {} };
try {
const cacheResponse = await fetch("/social-cache.json", { signal });
if (cacheResponse.ok) {
socialCache = await cacheResponse.json();
}
} catch {
/* Cache not available, continue without social info */
}
const repo = repoResult.data;
const recentCommits: CommitEntry[] = commitsResult.data.map((commit) => {
@@ -338,11 +391,23 @@ export function useGitHubStats(): UseGitHubStatsReturn {
weeklyActivity: Array.isArray(activityResult.data)
? activityResult.data
: [],
stargazers: stargazersResult.data.map((user) => ({
login: user.login,
avatarUrl: user.avatar_url,
profileUrl: user.html_url,
})),
stargazers: stargazersResult.data.map((user) => {
const cacheEntry = socialCache.entries[user.login];
return {
login: user.login,
avatarUrl: user.avatar_url,
profileUrl: user.html_url,
mastodon: cacheEntry?.mastodon
? { handle: cacheEntry.mastodon.handle, url: cacheEntry.mastodon.url }
: undefined,
twitter: cacheEntry?.twitter
? { handle: cacheEntry.twitter.handle, url: cacheEntry.twitter.url }
: undefined,
bluesky: cacheEntry?.bluesky
? { handle: cacheEntry.bluesky.handle, url: cacheEntry.bluesky.url }
: undefined,
};
}),
loading: false,
error: null,
rateLimit: {
+39 -5
View File
@@ -16,22 +16,54 @@ export interface PopoverState<T> {
* popover can fade out in place instead of jumping to (0, 0).
*
* @returns `hover` (current state or null), `popover` (last known state for rendering),
* `show` (set new hover), `hide` (clear hover).
* `show` (set new hover), `hide` (clear hover), `cancelHide` (cancel pending hide).
*/
export function useHoverPopover<T>() {
export function useHoverPopover<T>(showDelay = 300, hideDelay = 150) {
const [hover, setHover] = useState<PopoverState<T> | null>(null);
const lastRef = useRef<PopoverState<T> | null>(null);
const showTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const hideTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
if (hover) lastRef.current = hover;
}, [hover]);
const show = useCallback((data: T, x: number, y: number) => {
setHover({ data, x, y });
const cancelShow = useCallback(() => {
if (showTimeoutRef.current) {
clearTimeout(showTimeoutRef.current);
showTimeoutRef.current = null;
}
}, []);
const cancelHide = useCallback(() => {
if (hideTimeoutRef.current) {
clearTimeout(hideTimeoutRef.current);
hideTimeoutRef.current = null;
}
}, []);
const show = useCallback((data: T, x: number, y: number) => {
cancelHide();
cancelShow();
showTimeoutRef.current = setTimeout(() => {
setHover({ data, x, y });
}, showDelay);
}, [cancelHide, cancelShow, showDelay]);
const hide = useCallback(() => {
setHover(null);
cancelShow();
cancelHide();
hideTimeoutRef.current = setTimeout(() => {
setHover(null);
}, hideDelay);
}, [cancelShow, cancelHide, hideDelay]);
// Cleanup on unmount
useEffect(() => {
return () => {
if (showTimeoutRef.current) clearTimeout(showTimeoutRef.current);
if (hideTimeoutRef.current) clearTimeout(hideTimeoutRef.current);
};
}, []);
return {
@@ -41,5 +73,7 @@ export function useHoverPopover<T>() {
popover: hover ?? lastRef.current,
show,
hide,
/** Cancel a pending hide (call when mouse enters popover). */
cancelHide,
};
}
+277
View File
@@ -0,0 +1,277 @@
{
"generatedAt": "2026-02-04T18:04:57.704Z",
"entries": {
"patriksvensson": {
"login": "patriksvensson",
"mastodon": {
"handle": "@patriksvensson@mstdn.social",
"url": "https://mstdn.social/@patriksvensson",
"source": "username-match",
"verified": false
},
"bluesky": {
"handle": "@patriksvensson.bsky.social",
"url": "https://bsky.app/profile/patriksvensson.bsky.social",
"source": "username-match",
"verified": false
},
"updatedAt": "2026-02-04T17:53:43.607Z"
},
"italodeverdade": {
"login": "italodeverdade",
"bluesky": {
"handle": "@italodeverdade.bsky.social",
"url": "https://bsky.app/profile/italodeverdade.bsky.social",
"source": "username-match",
"verified": false
},
"updatedAt": "2026-02-04T17:53:48.377Z"
},
"lemonmojo": {
"login": "lemonmojo",
"twitter": {
"handle": "@lemonmojo",
"url": "https://x.com/lemonmojo",
"source": "github",
"verified": false
},
"updatedAt": "2026-02-04T17:53:54.153Z"
},
"dan-hart": {
"login": "dan-hart",
"mastodon": {
"handle": "@codedbydan@mas.to",
"url": "https://mas.to/@codedbydan",
"source": "blog",
"verified": false
},
"bluesky": {
"handle": "@dan-hart.bsky.social",
"url": "https://bsky.app/profile/dan-hart.bsky.social",
"source": "username-match",
"verified": false
},
"updatedAt": "2026-02-04T17:53:54.620Z"
},
"defagos": {
"login": "defagos",
"mastodon": {
"handle": "@defagos@mstdn.social",
"url": "https://mstdn.social/@defagos",
"source": "username-match",
"verified": false
},
"twitter": {
"handle": "@defagos",
"url": "https://x.com/defagos",
"source": "github",
"verified": false
},
"bluesky": {
"handle": "@defagos.bsky.social",
"url": "https://bsky.app/profile/defagos.bsky.social",
"source": "username-match",
"verified": false
},
"updatedAt": "2026-02-04T17:53:55.749Z"
},
"imcat": {
"login": "imcat",
"bluesky": {
"handle": "@imcat.bsky.social",
"url": "https://bsky.app/profile/imcat.bsky.social",
"source": "username-match",
"verified": false
},
"updatedAt": "2026-02-04T17:54:00.216Z"
},
"xiguagua": {
"login": "xiguagua",
"mastodon": {
"handle": "@xiguagua@mastodon.social",
"url": "https://mastodon.social/@xiguagua",
"source": "username-match",
"verified": false
},
"bluesky": {
"handle": "@xiguagua.bsky.social",
"url": "https://bsky.app/profile/xiguagua.bsky.social",
"source": "username-match",
"verified": false
},
"updatedAt": "2026-02-04T17:54:00.823Z"
},
"sqwu": {
"login": "sqwu",
"mastodon": {
"handle": "@menghang@bento.me",
"url": "https://bento.me/@menghang",
"source": "blog",
"verified": false
},
"updatedAt": "2026-02-04T17:54:12.594Z"
},
"metropol": {
"login": "metropol",
"bluesky": {
"handle": "@metropol.bsky.social",
"url": "https://bsky.app/profile/metropol.bsky.social",
"source": "username-match",
"verified": false
},
"updatedAt": "2026-02-04T17:54:17.411Z"
},
"gahms": {
"login": "gahms",
"mastodon": {
"handle": "@henriksen@knowit.dk",
"url": "https://knowit.dk/@henriksen",
"source": "bio",
"verified": false
},
"bluesky": {
"handle": "@gahms.bsky.social",
"url": "https://bsky.app/profile/gahms.bsky.social",
"source": "username-match",
"verified": false
},
"updatedAt": "2026-02-04T17:54:17.893Z"
},
"palaniraja": {
"login": "palaniraja",
"mastodon": {
"handle": "@palaniraja@mastodon.social",
"url": "https://mastodon.social/@palaniraja",
"source": "username-match",
"verified": false
},
"bluesky": {
"handle": "@palaniraja.bsky.social",
"url": "https://bsky.app/profile/palaniraja.bsky.social",
"source": "username-match",
"verified": false
},
"updatedAt": "2026-02-04T17:54:18.329Z"
},
"obrhoff": {
"login": "obrhoff",
"mastodon": {
"handle": "@obrhoff@mastodon.social",
"url": "https://mastodon.social/@obrhoff",
"source": "username-match",
"verified": false
},
"updatedAt": "2026-02-04T17:54:24.570Z"
},
"akoslowski": {
"login": "akoslowski",
"bluesky": {
"handle": "@akoslowski.bsky.social",
"url": "https://bsky.app/profile/akoslowski.bsky.social",
"source": "username-match",
"verified": false
},
"updatedAt": "2026-02-04T17:54:29.255Z"
},
"alladinian": {
"login": "alladinian",
"mastodon": {
"handle": "@alladinian@mastodon.social",
"url": "https://mastodon.social/@alladinian",
"source": "username-match",
"verified": false
},
"twitter": {
"handle": "@alladinian",
"url": "https://x.com/alladinian",
"source": "github",
"verified": false
},
"bluesky": {
"handle": "@alladinian.bsky.social",
"url": "https://bsky.app/profile/alladinian.bsky.social",
"source": "username-match",
"verified": false
},
"updatedAt": "2026-02-04T17:54:29.848Z"
},
"jasongregori": {
"login": "jasongregori",
"mastodon": {
"handle": "@jasongregori@hachyderm.io",
"url": "https://hachyderm.io/@jasongregori",
"source": "username-match",
"verified": false
},
"updatedAt": "2026-02-04T17:54:32.227Z"
},
"interstateone": {
"login": "interstateone",
"mastodon": {
"handle": "@interstateone@mastodon.social",
"url": "https://mastodon.social/@interstateone",
"source": "username-match",
"verified": false
},
"twitter": {
"handle": "@interstateone",
"url": "https://x.com/interstateone",
"source": "github",
"verified": false
},
"updatedAt": "2026-02-04T17:54:33.411Z"
},
"n0an": {
"login": "n0an",
"twitter": {
"handle": "@_antonnovoselov",
"url": "https://x.com/_antonnovoselov",
"source": "github",
"verified": false
},
"bluesky": {
"handle": "@n0an.bsky.social",
"url": "https://bsky.app/profile/n0an.bsky.social",
"source": "username-match",
"verified": false
},
"updatedAt": "2026-02-04T18:04:44.145Z"
},
"jtvargas": {
"login": "jtvargas",
"mastodon": {
"handle": "@apps@go.jrtv.space",
"url": "https://go.jrtv.space/@apps",
"source": "blog",
"verified": false
},
"updatedAt": "2026-02-04T18:04:45.102Z"
},
"erdaltoprak": {
"login": "erdaltoprak",
"mastodon": {
"handle": "@erdaltoprak@mastodon.social",
"url": "https://mastodon.social/@erdaltoprak",
"source": "username-match",
"verified": false
},
"twitter": {
"handle": "@erdalxtoprak",
"url": "https://x.com/erdalxtoprak",
"source": "github",
"verified": false
},
"updatedAt": "2026-02-04T18:04:52.068Z"
},
"IvanShevy": {
"login": "IvanShevy",
"twitter": {
"handle": "@Ivan_Shevy",
"url": "https://x.com/Ivan_Shevy",
"source": "github",
"verified": false
},
"updatedAt": "2026-02-04T18:04:57.704Z"
}
}
}
+948
View File
@@ -0,0 +1,948 @@
#!/usr/bin/env npx tsx
/**
* Social Cache Updater
*
* Finds social accounts (Mastodon, Twitter, Bluesky) for GitHub stargazers using multiple strategies:
* 1. Manual overrides (highest priority)
* 2. Parse GitHub bio for handles
* 3. Parse GitHub blog URL for profile links
* 4. Search username on known platforms
*
* Supports incremental updates (only new stargazers) and weekly full refresh.
*/
import * as fs from "fs";
import * as path from "path";
// ─────────────────────────────────────────────────────────────────────────────
// Configuration
// ─────────────────────────────────────────────────────────────────────────────
const OWNER = "phranck";
const REPO = "TUIkit";
const GITHUB_API = "https://api.github.com";
/** Known Mastodon instances to search for username matches. */
const KNOWN_MASTODON_INSTANCES = [
"mastodon.social",
"mastodon.online",
"mstdn.social",
"fosstodon.org",
"hachyderm.io",
"infosec.exchange",
"techhub.social",
"iosdev.space",
"indieweb.social",
"chaos.social",
"ruby.social",
"phpc.social",
];
/** Delay between API requests to avoid rate limiting (ms). */
const REQUEST_DELAY = 200;
// ─────────────────────────────────────────────────────────────────────────────
// Types
// ─────────────────────────────────────────────────────────────────────────────
interface SocialInfo {
handle: string;
url: string;
source: "github" | "bio" | "blog" | "username-match" | "manual" | "keybase" | "aboutme" | "linktree";
verified: boolean;
}
interface SocialCacheEntry {
login: string;
mastodon?: SocialInfo;
twitter?: SocialInfo;
bluesky?: SocialInfo;
updatedAt: string;
}
interface SocialCache {
generatedAt: string | null;
entries: Record<string, SocialCacheEntry>;
}
interface SocialOverrides {
overrides: Record<string, {
mastodon?: { handle: string; url: string };
twitter?: { handle: string; url: string };
bluesky?: { handle: string; url: string };
}>;
}
interface GitHubUser {
login: string;
avatar_url: string;
html_url: string;
bio?: string | null;
blog?: string | null;
twitter_username?: string | null;
}
interface GitHubStargazer {
login: string;
avatar_url: string;
html_url: string;
}
// ─────────────────────────────────────────────────────────────────────────────
// Avatar Comparison
// ─────────────────────────────────────────────────────────────────────────────
/**
* Fetches an image and returns a hash for comparison.
* Uses djb2 algorithm with file size for better collision resistance.
*/
async function fetchAvatarHash(url: string): Promise<string | null> {
try {
const response = await fetch(url, {
headers: { "User-Agent": "TUIKit-Social-Lookup" },
signal: AbortSignal.timeout(5000),
});
if (!response.ok) return null;
const buffer = await response.arrayBuffer();
const bytes = new Uint8Array(buffer);
// djb2 hash with denser sampling for better uniqueness
let hash = 5381;
for (let i = 0; i < bytes.length; i += 50) {
hash = ((hash << 5) + hash) ^ bytes[i];
}
// Include file size for additional collision resistance
return `${bytes.length.toString(16)}-${(hash >>> 0).toString(16)}`;
} catch {
return null;
}
}
/**
* Compares two avatar URLs to check if they're likely the same image.
* Returns true if avatars match, false otherwise.
*/
async function avatarsMatch(url1: string, url2: string): Promise<boolean> {
const [hash1, hash2] = await Promise.all([
fetchAvatarHash(url1),
fetchAvatarHash(url2),
]);
if (!hash1 || !hash2) return false;
return hash1 === hash2;
}
// ─────────────────────────────────────────────────────────────────────────────
// Profile Lookup Services
// ─────────────────────────────────────────────────────────────────────────────
interface ProfileSocialAccounts {
mastodon?: SocialInfo;
twitter?: SocialInfo;
bluesky?: SocialInfo;
}
// ─────────────────────────────────────────────────────────────────────────────
// About.me Profile Lookup
// ─────────────────────────────────────────────────────────────────────────────
/**
* Fetches social accounts from about.me profile by username.
* Scrapes the profile page for social links.
*/
async function fetchSocialFromAboutMe(username: string): Promise<ProfileSocialAccounts> {
const accounts: ProfileSocialAccounts = {};
try {
const url = `https://about.me/${username}`;
const response = await fetch(url, {
headers: { "User-Agent": "TUIKit-Social-Lookup" },
redirect: "follow",
});
if (!response.ok) return accounts;
const html = await response.text();
// Look for Twitter/X links
const twitterMatch = html.match(/href="https?:\/\/(?:twitter\.com|x\.com)\/([a-zA-Z0-9_]+)"/i);
if (twitterMatch) {
accounts.twitter = {
handle: `@${twitterMatch[1]}`,
url: `https://x.com/${twitterMatch[1]}`,
source: "aboutme",
verified: true,
};
console.log(` Found Twitter from about.me: @${twitterMatch[1]}`);
}
// Look for Mastodon links (exclude twitter/x.com false positives)
const mastodonMatch = html.match(/href="(https?:\/\/([a-zA-Z0-9.-]+)\/@([a-zA-Z0-9_]+))"/i);
if (mastodonMatch && !mastodonMatch[2].includes("twitter") && !mastodonMatch[2].includes("x.com")) {
accounts.mastodon = {
handle: `@${mastodonMatch[3]}@${mastodonMatch[2]}`,
url: mastodonMatch[1],
source: "aboutme",
verified: true,
};
console.log(` Found Mastodon from about.me: @${mastodonMatch[3]}@${mastodonMatch[2]}`);
}
// Look for Bluesky links
const bskyMatch = html.match(/href="https?:\/\/bsky\.app\/profile\/([^"]+)"/i);
if (bskyMatch) {
accounts.bluesky = {
handle: `@${bskyMatch[1]}`,
url: `https://bsky.app/profile/${bskyMatch[1]}`,
source: "aboutme",
verified: true,
};
console.log(` Found Bluesky from about.me: @${bskyMatch[1]}`);
}
} catch {
// about.me lookup failed, continue
}
await new Promise((resolve) => setTimeout(resolve, REQUEST_DELAY));
return accounts;
}
// ─────────────────────────────────────────────────────────────────────────────
// Keybase Profile Lookup
// ─────────────────────────────────────────────────────────────────────────────
/**
* Fetches social accounts from Keybase by GitHub username.
* Keybase has cryptographically verified identities.
*/
async function fetchSocialFromKeybase(githubUsername: string): Promise<ProfileSocialAccounts> {
const accounts: ProfileSocialAccounts = {};
try {
// Keybase API to find user by GitHub proof
const url = `https://keybase.io/_/api/1.0/user/lookup.json?github=${githubUsername}`;
const response = await fetch(url, {
headers: { "User-Agent": "TUIKit-Social-Lookup" },
});
if (!response.ok) return accounts;
const data = (await response.json()) as {
them?: Array<{
proofs_summary?: {
all?: Array<{
proof_type: string;
nametag: string;
service_url: string;
}>;
};
}>;
};
const proofs = data.them?.[0]?.proofs_summary?.all;
if (!proofs) return accounts;
for (const proof of proofs) {
if (proof.proof_type === "twitter") {
accounts.twitter = {
handle: `@${proof.nametag}`,
url: proof.service_url || `https://x.com/${proof.nametag}`,
source: "keybase",
verified: true, // Keybase proofs are cryptographically verified!
};
console.log(` Found Twitter from Keybase: @${proof.nametag} (verified ✓)`);
}
if (proof.proof_type === "mastodon" || proof.proof_type.includes("mastodon")) {
const mastodonMatch = proof.service_url?.match(/https?:\/\/([^/]+)\/@?([^/]+)/);
if (mastodonMatch) {
accounts.mastodon = {
handle: `@${mastodonMatch[2]}@${mastodonMatch[1]}`,
url: proof.service_url,
source: "keybase",
verified: true,
};
console.log(` Found Mastodon from Keybase: @${mastodonMatch[2]}@${mastodonMatch[1]} (verified ✓)`);
}
}
}
} catch {
// Keybase lookup failed, continue
}
await new Promise((resolve) => setTimeout(resolve, REQUEST_DELAY));
return accounts;
}
// ─────────────────────────────────────────────────────────────────────────────
// Linktree Profile Lookup
// ─────────────────────────────────────────────────────────────────────────────
/**
* Fetches social accounts from Linktree by username.
* Scrapes the public profile page for social links.
*/
async function fetchSocialFromLinktree(username: string): Promise<ProfileSocialAccounts> {
const accounts: ProfileSocialAccounts = {};
try {
const url = `https://linktr.ee/${username}`;
const response = await fetch(url, {
headers: { "User-Agent": "TUIKit-Social-Lookup" },
redirect: "follow",
});
if (!response.ok) return accounts;
const html = await response.text();
// Look for Twitter/X links
const twitterMatch = html.match(/href="https?:\/\/(?:twitter\.com|x\.com)\/([a-zA-Z0-9_]+)"/i);
if (twitterMatch) {
accounts.twitter = {
handle: `@${twitterMatch[1]}`,
url: `https://x.com/${twitterMatch[1]}`,
source: "linktree",
verified: true,
};
console.log(` Found Twitter from Linktree: @${twitterMatch[1]}`);
}
// Look for Mastodon links (various instances)
const mastodonMatch = html.match(/href="(https?:\/\/([a-zA-Z0-9.-]+)\/@([a-zA-Z0-9_]+))"/i);
if (mastodonMatch && !mastodonMatch[2].includes("twitter") && !mastodonMatch[2].includes("x.com")) {
accounts.mastodon = {
handle: `@${mastodonMatch[3]}@${mastodonMatch[2]}`,
url: mastodonMatch[1],
source: "linktree",
verified: true,
};
console.log(` Found Mastodon from Linktree: @${mastodonMatch[3]}@${mastodonMatch[2]}`);
}
// Look for Bluesky links
const bskyMatch = html.match(/href="https?:\/\/bsky\.app\/profile\/([^"]+)"/i);
if (bskyMatch) {
accounts.bluesky = {
handle: `@${bskyMatch[1]}`,
url: `https://bsky.app/profile/${bskyMatch[1]}`,
source: "linktree",
verified: true,
};
console.log(` Found Bluesky from Linktree: @${bskyMatch[1]}`);
}
} catch {
// Linktree lookup failed, continue
}
await new Promise((resolve) => setTimeout(resolve, REQUEST_DELAY));
return accounts;
}
// ─────────────────────────────────────────────────────────────────────────────
// GitHub API
// ─────────────────────────────────────────────────────────────────────────────
function getGitHubHeaders(): Record<string, string> {
const headers: Record<string, string> = {
Accept: "application/vnd.github+json",
"User-Agent": "TUIKit-Social-Lookup",
};
const token = process.env.GITHUB_TOKEN;
if (token) {
headers.Authorization = `Bearer ${token}`;
}
return headers;
}
async function fetchStargazers(): Promise<GitHubStargazer[]> {
const stargazers: GitHubStargazer[] = [];
let page = 1;
const perPage = 100;
while (true) {
const url = `${GITHUB_API}/repos/${OWNER}/${REPO}/stargazers?per_page=${perPage}&page=${page}`;
const response = await fetch(url, { headers: getGitHubHeaders() });
if (!response.ok) {
throw new Error(`GitHub API error: ${response.status} ${response.statusText}`);
}
const data = (await response.json()) as GitHubStargazer[];
stargazers.push(...data);
if (data.length < perPage) break;
page++;
}
console.log(`Fetched ${stargazers.length} stargazers from GitHub`);
return stargazers;
}
async function fetchUserDetails(login: string): Promise<GitHubUser | null> {
try {
const url = `${GITHUB_API}/users/${login}`;
const response = await fetch(url, { headers: getGitHubHeaders() });
if (!response.ok) {
console.warn(` Failed to fetch user ${login}: ${response.status}`);
return null;
}
return (await response.json()) as GitHubUser;
} catch (error) {
console.warn(` Error fetching user ${login}:`, error);
return null;
}
}
// ─────────────────────────────────────────────────────────────────────────────
// Social Detection - Mastodon
// ─────────────────────────────────────────────────────────────────────────────
/** Matches @username@instance.tld or username@instance.tld in text. */
const MASTODON_HANDLE_REGEX = /@?([a-zA-Z0-9_]+)@([a-zA-Z0-9.-]+\.[a-zA-Z]{2,})/g;
/** Matches Mastodon profile URLs. */
const MASTODON_URL_REGEX = /https?:\/\/([a-zA-Z0-9.-]+)\/(@|users\/)?([a-zA-Z0-9_]+)\/?$/i;
function parseMastodonFromBio(bio: string): SocialInfo | null {
const matches = [...bio.matchAll(MASTODON_HANDLE_REGEX)];
if (matches.length === 0) return null;
const [, username, instance] = matches[0];
// Exclude email-like patterns (common email providers)
const emailProviders = [
"gmail", "outlook", "yahoo", "hotmail", "icloud", "protonmail", "hey.com",
"mail.com", "aol.com", "live.com", "msn.com", "yandex", "zoho",
];
if (emailProviders.some((provider) => instance.toLowerCase().includes(provider))) {
return null;
}
return {
handle: `@${username}@${instance}`,
url: `https://${instance}/@${username}`,
source: "bio",
verified: false,
};
}
function parseMastodonFromBlog(blog: string): SocialInfo | null {
if (!blog) return null;
const match = blog.match(MASTODON_URL_REGEX);
if (!match) return null;
const [, instance, , username] = match;
// Blocklist non-Mastodon sites (bsky.app uses /profile/ not /@, so won't match anyway)
const blocklist = ["twitter.com", "x.com", "github.com", "linkedin.com", "facebook.com", "instagram.com", "youtube.com"];
if (blocklist.some((blocked) => instance.includes(blocked))) {
return null;
}
return {
handle: `@${username}@${instance}`,
url: `https://${instance}/@${username}`,
source: "blog",
verified: false,
};
}
async function searchMastodonByUsername(username: string, githubAvatarUrl: string): Promise<SocialInfo | null> {
for (const instance of KNOWN_MASTODON_INSTANCES) {
try {
const url = `https://${instance}/api/v1/accounts/lookup?acct=${username}`;
const response = await fetch(url, {
headers: { "User-Agent": "TUIKit-Social-Lookup" },
});
if (response.ok) {
const data = (await response.json()) as { username: string; url: string; avatar: string };
// Verify by comparing avatars
const match = await avatarsMatch(githubAvatarUrl, data.avatar);
if (match) {
console.log(` Found Mastodon @${username} on ${instance} (avatar verified ✓)`);
return {
handle: `@${data.username}@${instance}`,
url: data.url,
source: "username-match",
verified: true,
};
} else {
console.log(` Skipping @${username} on ${instance} (avatar mismatch)`);
}
}
} catch {
// Instance unreachable or rate limited, continue
}
await new Promise((resolve) => setTimeout(resolve, REQUEST_DELAY));
}
return null;
}
// ─────────────────────────────────────────────────────────────────────────────
// Social Detection - Twitter/X
// ─────────────────────────────────────────────────────────────────────────────
/** Matches Twitter/X profile URLs. */
const TWITTER_URL_REGEX = /https?:\/\/(?:twitter\.com|x\.com)\/([a-zA-Z0-9_]+)\/?$/i;
function parseTwitterFromBio(bio: string): SocialInfo | null {
// Look for explicit Twitter mentions or X.com links
const urlMatch = bio.match(TWITTER_URL_REGEX);
if (urlMatch) {
const username = urlMatch[1];
return {
handle: `@${username}`,
url: `https://x.com/${username}`,
source: "bio",
verified: false,
};
}
return null;
}
function parseTwitterFromBlog(blog: string): SocialInfo | null {
if (!blog) return null;
const match = blog.match(TWITTER_URL_REGEX);
if (!match) return null;
const username = match[1];
return {
handle: `@${username}`,
url: `https://x.com/${username}`,
source: "blog",
verified: false,
};
}
function getTwitterFromGitHubProfile(user: GitHubUser): SocialInfo | undefined {
if (user.twitter_username) {
console.log(` Found Twitter from GitHub profile: @${user.twitter_username}`);
return {
handle: `@${user.twitter_username}`,
url: `https://x.com/${user.twitter_username}`,
source: "github",
verified: true, // GitHub's twitter_username field is user-provided and authoritative
};
}
return undefined;
}
// ─────────────────────────────────────────────────────────────────────────────
// Social Detection - Bluesky
// ─────────────────────────────────────────────────────────────────────────────
/** Matches Bluesky handles like @user.bsky.social or user.bsky.social in text. */
const BLUESKY_HANDLE_REGEX = /@?([a-zA-Z0-9.-]+\.bsky\.social)/gi;
/** Matches Bluesky profile URLs. */
const BLUESKY_URL_REGEX = /https?:\/\/bsky\.app\/profile\/([a-zA-Z0-9.-]+)/i;
function parseBlueskyFromBio(bio: string): SocialInfo | null {
// Check for bsky.app URLs first
const urlMatch = bio.match(BLUESKY_URL_REGEX);
if (urlMatch) {
const handle = urlMatch[1];
return {
handle: `@${handle}`,
url: `https://bsky.app/profile/${handle}`,
source: "bio",
verified: false,
};
}
// Check for .bsky.social handles
const handleMatches = [...bio.matchAll(BLUESKY_HANDLE_REGEX)];
if (handleMatches.length > 0) {
const handle = handleMatches[0][1];
return {
handle: `@${handle}`,
url: `https://bsky.app/profile/${handle}`,
source: "bio",
verified: false,
};
}
return null;
}
function parseBlueskyFromBlog(blog: string): SocialInfo | null {
if (!blog) return null;
const match = blog.match(BLUESKY_URL_REGEX);
if (!match) return null;
const handle = match[1];
return {
handle: `@${handle}`,
url: `https://bsky.app/profile/${handle}`,
source: "blog",
verified: false,
};
}
async function searchBlueskyByUsername(username: string, githubAvatarUrl: string): Promise<SocialInfo | null> {
// Try Bluesky handle - use lowercase since handles are case-insensitive
const handle = `${username.toLowerCase()}.bsky.social`;
try {
const url = `https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile?actor=${handle}`;
const response = await fetch(url, {
headers: { "User-Agent": "TUIKit-Social-Lookup" },
signal: AbortSignal.timeout(5000),
});
if (response.ok) {
const data = (await response.json()) as { handle: string; avatar?: string };
// Verify by comparing avatars if available
if (data.avatar) {
const match = await avatarsMatch(githubAvatarUrl, data.avatar);
if (match) {
console.log(` Found Bluesky @${data.handle} (avatar verified ✓)`);
return {
handle: `@${data.handle}`,
url: `https://bsky.app/profile/${data.handle}`,
source: "username-match",
verified: true,
};
} else {
console.log(` Skipping Bluesky @${data.handle} (avatar mismatch)`);
}
}
}
} catch {
// API error, continue
}
return null;
}
// ─────────────────────────────────────────────────────────────────────────────
// Combined Search
// ─────────────────────────────────────────────────────────────────────────────
async function findSocialAccounts(user: GitHubUser): Promise<ProfileSocialAccounts> {
const accounts: ProfileSocialAccounts = {};
const githubAvatarUrl = user.avatar_url;
// ── Try Keybase first (cryptographically verified, highest trust) ──
const keybaseAccounts = await fetchSocialFromKeybase(user.login);
if (keybaseAccounts.twitter) accounts.twitter = keybaseAccounts.twitter;
if (keybaseAccounts.mastodon) accounts.mastodon = keybaseAccounts.mastodon;
if (keybaseAccounts.bluesky) accounts.bluesky = keybaseAccounts.bluesky;
// ── Try about.me profile (high quality source) ──
if (!accounts.twitter || !accounts.mastodon || !accounts.bluesky) {
const aboutMeAccounts = await fetchSocialFromAboutMe(user.login);
if (!accounts.twitter && aboutMeAccounts.twitter) accounts.twitter = aboutMeAccounts.twitter;
if (!accounts.mastodon && aboutMeAccounts.mastodon) accounts.mastodon = aboutMeAccounts.mastodon;
if (!accounts.bluesky && aboutMeAccounts.bluesky) accounts.bluesky = aboutMeAccounts.bluesky;
}
// ── Try Linktree profile ──
if (!accounts.twitter || !accounts.mastodon || !accounts.bluesky) {
const linktreeAccounts = await fetchSocialFromLinktree(user.login);
if (!accounts.twitter && linktreeAccounts.twitter) accounts.twitter = linktreeAccounts.twitter;
if (!accounts.mastodon && linktreeAccounts.mastodon) accounts.mastodon = linktreeAccounts.mastodon;
if (!accounts.bluesky && linktreeAccounts.bluesky) accounts.bluesky = linktreeAccounts.bluesky;
}
// ── Twitter (check GitHub profile, it's authoritative) ──
if (!accounts.twitter) {
accounts.twitter = getTwitterFromGitHubProfile(user);
}
if (!accounts.twitter && user.bio) {
accounts.twitter = parseTwitterFromBio(user.bio) ?? undefined;
}
if (!accounts.twitter && user.blog) {
accounts.twitter = parseTwitterFromBlog(user.blog) ?? undefined;
}
// ── Bluesky ──
if (!accounts.bluesky && user.bio) {
accounts.bluesky = parseBlueskyFromBio(user.bio) ?? undefined;
}
if (!accounts.bluesky && user.blog) {
accounts.bluesky = parseBlueskyFromBlog(user.blog) ?? undefined;
}
if (!accounts.bluesky) {
accounts.bluesky = (await searchBlueskyByUsername(user.login, githubAvatarUrl)) ?? undefined;
}
// ── Mastodon ──
if (!accounts.mastodon && user.bio) {
accounts.mastodon = parseMastodonFromBio(user.bio) ?? undefined;
}
if (!accounts.mastodon && user.blog) {
accounts.mastodon = parseMastodonFromBlog(user.blog) ?? undefined;
}
if (!accounts.mastodon) {
accounts.mastodon = (await searchMastodonByUsername(user.login, githubAvatarUrl)) ?? undefined;
}
// ── Cross-platform verification ──
// If we found accounts, verify them by checking for back-links or matching data
await crossPlatformVerify(accounts, user);
return accounts;
}
// ─────────────────────────────────────────────────────────────────────────────
// Cross-Platform Verification
// ─────────────────────────────────────────────────────────────────────────────
/**
* Cross-references found accounts to increase confidence.
* Checks for:
* - GitHub links in social profiles (back-link verification)
* - Matching display names across platforms
* - Consistent avatar usage
*/
async function crossPlatformVerify(accounts: ProfileSocialAccounts, user: GitHubUser): Promise<void> {
const validationResults: string[] = [];
// Verify Mastodon by checking if profile links back to GitHub
if (accounts.mastodon && !accounts.mastodon.verified) {
const verified = await verifyMastodonLinksToGitHub(accounts.mastodon.url, user.login);
if (verified) {
accounts.mastodon.verified = true;
validationResults.push("Mastodon→GitHub ✓");
} else {
// Unverified username-match, remove it to avoid false positives
if (accounts.mastodon.source === "username-match") {
console.log(` Removing unverified Mastodon match (no back-link to GitHub)`);
delete accounts.mastodon;
}
}
}
// Verify Bluesky by checking if profile links back to GitHub
if (accounts.bluesky && !accounts.bluesky.verified) {
const verified = await verifyBlueskyLinksToGitHub(accounts.bluesky.handle.replace("@", ""), user.login);
if (verified) {
accounts.bluesky.verified = true;
validationResults.push("Bluesky→GitHub ✓");
} else {
// Unverified username-match, remove it
if (accounts.bluesky.source === "username-match") {
console.log(` Removing unverified Bluesky match (no back-link to GitHub)`);
delete accounts.bluesky;
}
}
}
if (validationResults.length > 0) {
console.log(` Cross-verified: ${validationResults.join(", ")}`);
}
}
/**
* Checks if a Mastodon profile links back to the GitHub user.
*/
async function verifyMastodonLinksToGitHub(mastodonUrl: string, githubUsername: string): Promise<boolean> {
try {
// Extract instance and username from URL
const match = mastodonUrl.match(/https?:\/\/([^/]+)\/@?([^/]+)/);
if (!match) return false;
const [, instance, username] = match;
const apiUrl = `https://${instance}/api/v1/accounts/lookup?acct=${username}`;
const response = await fetch(apiUrl, {
headers: { "User-Agent": "TUIKit-Social-Lookup" },
});
if (!response.ok) return false;
const data = (await response.json()) as {
note?: string;
fields?: Array<{ name: string; value: string }>;
};
// Check bio and fields for GitHub link
const githubPattern = new RegExp(`github\\.com/${githubUsername}`, "i");
if (data.note && githubPattern.test(data.note)) {
return true;
}
if (data.fields) {
for (const field of data.fields) {
if (githubPattern.test(field.value)) {
return true;
}
}
}
return false;
} catch {
return false;
}
}
/**
* Checks if a Bluesky profile links back to the GitHub user.
*/
async function verifyBlueskyLinksToGitHub(handle: string, githubUsername: string): Promise<boolean> {
try {
const url = `https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile?actor=${handle}`;
const response = await fetch(url, {
headers: { "User-Agent": "TUIKit-Social-Lookup" },
});
if (!response.ok) return false;
const data = (await response.json()) as { description?: string };
if (!data.description) return false;
// Check bio for GitHub link
const githubPattern = new RegExp(`github\\.com/${githubUsername}`, "i");
return githubPattern.test(data.description);
} catch {
return false;
}
}
// ─────────────────────────────────────────────────────────────────────────────
// File I/O
// ─────────────────────────────────────────────────────────────────────────────
const CACHE_PATH = path.join(__dirname, "../public/social-cache.json");
const OVERRIDES_PATH = path.join(__dirname, "../social-overrides.json");
function loadCache(): SocialCache {
try {
const content = fs.readFileSync(CACHE_PATH, "utf-8");
return JSON.parse(content) as SocialCache;
} catch {
return { generatedAt: null, entries: {} };
}
}
function saveCache(cache: SocialCache): void {
cache.generatedAt = new Date().toISOString();
fs.writeFileSync(CACHE_PATH, JSON.stringify(cache, null, 2) + "\n");
console.log(`Saved cache with ${Object.keys(cache.entries).length} entries`);
}
function loadOverrides(): SocialOverrides {
try {
const content = fs.readFileSync(OVERRIDES_PATH, "utf-8");
return JSON.parse(content) as SocialOverrides;
} catch {
return { overrides: {} };
}
}
// ─────────────────────────────────────────────────────────────────────────────
// Main
// ─────────────────────────────────────────────────────────────────────────────
async function main() {
const isFullRefresh = process.argv.includes("--full");
console.log(`Social Cache Update (${isFullRefresh ? "FULL REFRESH" : "incremental"})`);
console.log("=".repeat(60));
// Load existing data
const cache = loadCache();
const overrides = loadOverrides();
let stargazers: GitHubStargazer[];
try {
stargazers = await fetchStargazers();
} catch (err) {
console.error("Failed to fetch stargazers, retrying in 5s...");
await new Promise((r) => setTimeout(r, 5000));
stargazers = await fetchStargazers();
}
// Determine which users to process
const cachedLogins = new Set(Object.keys(cache.entries));
const currentLogins = new Set(stargazers.map((s) => s.login));
// Remove entries for users who unstarred
for (const login of cachedLogins) {
if (!currentLogins.has(login)) {
console.log(`Removing unstarred user: ${login}`);
delete cache.entries[login];
}
}
// Find new stargazers (not in cache)
const toProcess = isFullRefresh
? stargazers
: stargazers.filter((s) => !cachedLogins.has(s.login));
console.log(`\nProcessing ${toProcess.length} users...`);
for (const stargazer of toProcess) {
const { login } = stargazer;
console.log(`\nProcessing: ${login}`);
// Check manual overrides first
const override = overrides.overrides[login];
if (override) {
console.log(` Using manual override`);
const entry: SocialCacheEntry = {
login,
updatedAt: new Date().toISOString(),
};
if (override.mastodon) {
entry.mastodon = { ...override.mastodon, source: "manual", verified: true };
}
if (override.twitter) {
entry.twitter = { ...override.twitter, source: "manual", verified: true };
}
if (override.bluesky) {
entry.bluesky = { ...override.bluesky, source: "manual", verified: true };
}
cache.entries[login] = entry;
continue;
}
// Fetch user details and search for social accounts
const userDetails = await fetchUserDetails(login);
if (!userDetails) continue;
const accounts = await findSocialAccounts(userDetails);
if (accounts.mastodon || accounts.twitter || accounts.bluesky) {
cache.entries[login] = {
login,
mastodon: accounts.mastodon,
twitter: accounts.twitter,
bluesky: accounts.bluesky,
updatedAt: new Date().toISOString(),
};
const found = [
accounts.mastodon ? `Mastodon: ${accounts.mastodon.handle}` : null,
accounts.twitter ? `Twitter: ${accounts.twitter.handle}` : null,
accounts.bluesky ? `Bluesky: ${accounts.bluesky.handle}` : null,
].filter(Boolean).join(", ");
console.log(` Found: ${found}`);
} else {
console.log(" No social accounts found");
}
}
// Save updated cache
saveCache(cache);
console.log("\n" + "=".repeat(60));
console.log("Done!");
}
main().catch((error) => {
console.error("Fatal error:", error);
process.exit(1);
});
+5
View File
@@ -0,0 +1,5 @@
{
"$schema": "./social-overrides.schema.json",
"description": "Manual social account mappings for stargazers. These take priority over auto-detected accounts.",
"overrides": {}
}
+314
View File
@@ -0,0 +1,314 @@
# Stargazer Social Account Lookup — Multi-Platform Discovery
## Goal
Find social media accounts (Mastodon, Twitter/X, Bluesky) for all repository stargazers and display/link them in the Dashboard popover with platform-specific icons.
## Tech Stack
- GitHub Actions (Scheduled Workflow)
- Node.js/TypeScript script for social account search
- JSON file as social data cache (committed to repo)
- Next.js client-side fetch for live stargazer list
## Data Strategy: Hybrid Approach
**Key insight:** Stargazers are already fetched live via GitHub API on every page load. Only the social account lookup data needs caching (because it's expensive and slow).
```
┌─────────────────────────────────────────────────────────────┐
│ CLIENT (on every page load) │
│ ┌─────────────────┐ │
│ │ GitHub API │──▶ Live stargazer list (login, avatar) │
│ │ /stargazers │ Always up-to-date on reload! │
│ └─────────────────┘ │
└─────────────────────────────────────────────────────────────┘
+
┌─────────────────────────────────────────────────────────────┐
│ SCHEDULED (GitHub Action, every 2h) │
│ ┌─────────────┐ ┌──────────────┐ ┌───────────────┐ │
│ │ Fetch │───▶│ Search │───▶│ Generate │ │
│ │ Stargazers │ │ Social Accts │ │ JSON │ │
│ └─────────────┘ └──────────────┘ └───────────────┘ │
│ ▼ │
│ ┌────────────────────┐ │
│ │ social-cache.json │ │
│ │ (committed to repo)│ │
│ └────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
=
┌─────────────────────────────────────────────────────────────┐
│ MERGED at runtime │
│ ┌─────────────────┐ ┌────────────────────┐ │
│ │ Live stargazers │ + │ Cached Social │ │
│ │ from API │ │ from JSON │ │
│ └────────┬────────┘ └─────────┬──────────┘ │
│ │ │ │
│ └───────────┬───────────┘ │
│ ▼ │
│ ┌───────────────────────┐ │
│ │ Stargazer with social │ │
│ │ icons (if any found) │ │
│ └───────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
```
### Update Strategy: Incremental + Weekly Full Refresh
```
┌─────────────────────────────────────────────────────────────┐
│ FIRST RUN │
│ └── All stargazers → full social search │
├─────────────────────────────────────────────────────────────┤
│ EVERY 2 HOURS (incremental) │
│ └── Compare current stargazers with cache │
│ └── Only search for NEW stargazers │
│ └── Skip already-cached entries │
├─────────────────────────────────────────────────────────────┤
│ WEEKLY (Sundays 4 AM UTC) │
│ └── Full refresh of ALL stargazers │
│ └── Catches updated bios, changed handles │
│ └── Removes entries for users who unstarred │
└─────────────────────────────────────────────────────────────┘
```
## Supported Platforms
| Platform | Icon | Detection Methods |
|----------|------|-------------------|
| Twitter/X | 𝕏 | GitHub `twitter_username` field, bio parsing, blog URL |
| Mastodon | 🐘 | Bio parsing (`@user@instance`), blog URL, username search |
| Bluesky | 🦋 | Bio parsing (`.bsky.social`), blog URL, API search |
## API Details
### GitHub API
- **Rate Limits:**
- Unauthenticated: 60 req/hour
- With `GITHUB_TOKEN`: 1,000 req/hour
- With Personal Access Token: 5,000 req/hour ✅
- **Available data:**
| Endpoint | Fields |
|---|---|
| `GET /users/{username}` | `bio`, `blog`, `twitter_username`, `avatar_url`, `name` |
### Platform APIs
| Platform | Endpoint | Notes |
|---|---|---|
| Mastodon | `https://{instance}/api/v1/accounts/lookup?acct={username}` | Need to know instance |
| Bluesky | `https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile?actor={handle}` | Public, no auth needed |
| Twitter | N/A (no public API) | Only parse from GitHub profile |
## Search Strategies (Prioritized)
| Priority | Source | Method | Reliability |
|---|---|---|---|
| 1 | Manual Overrides | `social-overrides.json` | ✅ 100% |
| 2 | Gravatar Profile | JSON API with verified accounts | ✅ High |
| 3 | About.me Profile | Scrape social links | ✅ High |
| 4 | GitHub Profile | `twitter_username` field | ✅ 100% (for Twitter) |
| 5 | GitHub Bio | Regex for handles | ✅ High |
| 6 | GitHub Blog | URL parsing | ✅ High |
| 7 | Username Match + Avatar | Same username + same avatar image | ✅ High |
## Regex Patterns
```typescript
// Mastodon: @username@instance.tld or username@instance.tld
const MASTODON_HANDLE_REGEX = /@?([a-zA-Z0-9_]+)@([a-zA-Z0-9.-]+\.[a-zA-Z]{2,})/g;
// Bluesky: @handle.bsky.social or handle.bsky.social
const BLUESKY_HANDLE_REGEX = /@?([a-zA-Z0-9.-]+\.bsky\.social)/gi;
// Twitter: @username (simple, combined with context)
const TWITTER_HANDLE_REGEX = /@([a-zA-Z0-9_]{1,15})/g;
```
## Data Types
```typescript
interface SocialInfo {
handle: string;
url: string;
source: "github" | "bio" | "blog" | "username-match" | "manual";
verified: boolean;
}
interface SocialCacheEntry {
login: string;
mastodon?: SocialInfo;
twitter?: SocialInfo;
bluesky?: SocialInfo;
updatedAt: string;
}
interface SocialCache {
generatedAt: string | null;
entries: Record<string, SocialCacheEntry>;
}
// Runtime merged type (simpler, no source/verified)
interface Stargazer {
login: string;
avatarUrl: string;
profileUrl: string;
mastodon?: { handle: string; url: string };
twitter?: { handle: string; url: string };
bluesky?: { handle: string; url: string };
}
```
## Files
### New Files
| File | Purpose |
|---|---|
| `docs/scripts/update-social-cache.ts` | Main script for social account search |
| `docs/public/social-cache.json` | Cached social data (served statically) |
| `docs/social-overrides.json` | Manual corrections/mappings |
| `.github/workflows/update-social-cache.yml` | Scheduled workflow |
### Modified Files
| File | Change |
|---|---|
| `docs/app/hooks/useGitHubStats.ts` | Fetch + merge social-cache.json |
| `docs/app/components/StargazersPanel.tsx` | Popover with social icons + links |
| `docs/app/components/Icon.tsx` | Add Mastodon, Twitter, Bluesky icons |
## UI Design
### Popover with Social Icons
```
┌─────────────────────────────────┐
│ phranck │
│ 🐘 Mastodon │
│ 🦋 Bluesky │
𝕏 Twitter │
└─────────────────────────────────┘
```
- GitHub username always visible (centered, top)
- Social accounts listed below with icon + platform name
- Order: Mastodon, Bluesky, Twitter
- Icons are **monochrome only** (colored by theme)
- Each line is a clickable link to the profile
- Only show platforms where account was found
## GitHub Action Workflow
```yaml
name: Update Social Cache
on:
schedule:
- cron: '0 */2 * * *' # Every 2 hours (incremental)
- cron: '0 4 * * 0' # Weekly full refresh (Sundays 4 AM UTC)
workflow_dispatch:
inputs:
full_refresh:
description: 'Run full refresh instead of incremental'
type: boolean
default: false
jobs:
update:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '22'
- run: npm ci
working-directory: docs
- name: Update social cache
run: npx tsx scripts/update-social-cache.ts ${{ steps.mode.outputs.args }}
working-directory: docs
env:
GITHUB_TOKEN: ${{ secrets.DASHBOARD_GITHUB_TOKEN }}
- name: Commit if changed
run: |
git diff --quiet docs/public/social-cache.json && exit 0
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add docs/public/social-cache.json
git commit -m "chore: update social cache [skip ci]"
git push
```
## Implementation Plan
### Phase 1: Data Structure & Runtime Merge
- [x] Define `SocialCacheEntry` type (with all three platforms)
- [x] Create empty `social-overrides.json`
- [x] Create empty `public/social-cache.json`
- [x] Update `useGitHubStats.ts` to fetch and merge social cache
### Phase 2: Social Search Script
- [x] Create `scripts/update-social-cache.ts`
- [x] Fetch GitHub user details (bio, blog, twitter_username)
- [x] Parse Twitter from GitHub profile + bio/blog
- [x] Parse Mastodon handle from bio/blog
- [x] Parse Bluesky handle from bio/blog
- [x] Search username on known Mastodon instances
- [x] Search username via Bluesky API
- [x] Merge with manual overrides
- [x] Write `social-cache.json`
### Phase 3: Scheduled Workflow
- [x] Create `update-social-cache.yml` workflow
- [x] Configure incremental (2h) + full (weekly) schedule
- [x] Use `DASHBOARD_GITHUB_TOKEN` secret
- [ ] Test manual trigger
### Phase 4: UI Updates
- [x] Add Mastodon, Twitter, Bluesky icons to `Icon.tsx`
- [x] Update `StargazersPanel.tsx` popover with icon row
- [x] Link icons to social profiles
- [x] Add tooltips with full handles
### Phase 5: Testing & Finalization
- [x] Fix TypeScript errors in script
- [x] Run `npm run build` successfully
- [x] Run script locally to verify
- [ ] Test popover in browser
- [ ] Add manual overrides for known stargazers
## Risks & Mitigations
| Risk | Mitigation |
|---|---|
| Rate Limiting (Mastodon) | Max 1 req/s per instance, parallel instances OK |
| Rate Limiting (Bluesky) | Public API, generous limits |
| False positive matches | `verified: false` flag, manual overrides |
| Instance offline | Try/catch, skip and retry on next run |
| Many stargazers | Incremental updates (only check new ones) |
## Known Mastodon Instances
```typescript
const KNOWN_INSTANCES = [
"mastodon.social",
"mastodon.online",
"mstdn.social",
"fosstodon.org",
"hachyderm.io",
"infosec.exchange",
"techhub.social",
"iosdev.space",
"indieweb.social",
"chaos.social",
"ruby.social",
"phpc.social",
];
```