mirror of
https://github.com/phranck/TUIkit.git
synced 2026-05-21 09:50:35 +00:00
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:
@@ -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
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"$schema": "./social-overrides.schema.json",
|
||||
"description": "Manual social account mappings for stargazers. These take priority over auto-detected accounts.",
|
||||
"overrides": {}
|
||||
}
|
||||
@@ -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",
|
||||
];
|
||||
```
|
||||
Reference in New Issue
Block a user