import type { ServiceProvider } from "./types.js"; import * as cheerio from "cheerio"; /** * Hacker News service provider * * Users can prove ownership of their Hacker News account in two ways: * 1. Add their DID to their profile's "about" field * 2. Create a comment or post containing their DID * * Fetching uses HTML scraping of HN pages for real-time verification * (HN's Firebase API has significant indexing delays). */ const hackernews: ServiceProvider = { id: "hackernews", name: "Hacker News", homepage: "https://news.ycombinator.com", // Match both user profile URLs and item URLs // User: https://news.ycombinator.com/user?id=username // Item: https://news.ycombinator.com/item?id=12345678 reUri: /^https:\/\/(news|old)\.ycombinator\.com\/(user\?id=([a-zA-Z0-9_-]+)|item\?id=(\d+))$/, isAmbiguous: false, ui: { description: "Link via your Hacker News profile or post", icon: "message-square", inputLabel: "Hacker News Profile or Item URL", inputPlaceholder: "https://news.ycombinator.com/user?id=username", instructions: [ "**Option 1:** Add to your profile's about field at [profile settings](https://news.ycombinator.com/user)", "**Option 2:** Create a comment in the [Keytrace Verification Thread](https://news.ycombinator.com/item?id=47194380) containing the verification content", "Copy the URL of your profile or comment", "Paste the URL below", ], proofTemplate: "I'm linking my keytrace.dev: {did}", }, processURI(uri, match) { const [, , , username, itemId] = match; // Determine if this is a user profile or an item (comment/post) const isUserProfile = username !== undefined; if (isUserProfile) { // User profile verification - fetch HTML directly return { profile: { display: username, uri: `https://news.ycombinator.com/user?id=${username}`, }, proof: { request: { uri, fetcher: "http", format: "html", options: { headers: { "User-Agent": "keytrace-runner/1.0 (identity verification bot)", }, }, }, target: [ // Check the about field in the user profile: about:content { css: 'tr:has(td:contains("about:")) td:last-child', relation: "contains", format: "text", }, ], }, }; } else { // Item (comment/post) verification - fetch HTML directly return { profile: { display: "Hacker News", uri: "https://news.ycombinator.com", }, proof: { request: { uri, fetcher: "http", format: "html", options: { headers: { "User-Agent": "keytrace-runner/1.0 (identity verification bot)", }, }, }, target: [ // Check comment/post text:
or
{ css: ".commtext, .toptext", relation: "contains", format: "text", }, // Also check story title: or look for title in athing row { css: "span.titleline a, tr.athing td.title a", relation: "contains", format: "text", }, ], }, }; } }, postprocess(data, match) { const [, , , username] = match; const isUserProfile = username !== undefined; // Data is raw HTML when using format: "html" const html = data as string; // Use cheerio to extract username from HTML // For profiles: already have username from URL // For items: extract from if (isUserProfile) { return { subject: username, displayName: username, profileUrl: `https://news.ycombinator.com/user?id=${username}`, avatarUrl: undefined, }; } else { // Extract username from item page using cheerio const $ = cheerio.load(html); const itemUsername = $("a.hnuser").first().text().trim() || "unknown"; return { subject: itemUsername, displayName: itemUsername, profileUrl: itemUsername !== "unknown" ? `https://news.ycombinator.com/user?id=${itemUsername}` : undefined, avatarUrl: undefined, }; } }, getProofText(did) { return `I'm linking my keytrace.dev: ${did}`; }, getProofLocation() { return `Add your DID to your Hacker News profile's about field, or create a post/comment containing it`; }, tests: [ // User profile URLs { uri: "https://news.ycombinator.com/user?id=alice", shouldMatch: true }, { uri: "https://news.ycombinator.com/user?id=bob123", shouldMatch: true }, { uri: "https://news.ycombinator.com/user?id=user_name", shouldMatch: true }, { uri: "https://news.ycombinator.com/user?id=user-name", shouldMatch: true }, { uri: "https://old.ycombinator.com/user?id=alice", shouldMatch: true }, // Item URLs (comments/posts) { uri: "https://news.ycombinator.com/item?id=12345678", shouldMatch: true }, { uri: "https://news.ycombinator.com/item?id=1", shouldMatch: true }, { uri: "https://news.ycombinator.com/item?id=99999999", shouldMatch: true }, { uri: "https://old.ycombinator.com/item?id=12345678", shouldMatch: true }, // Should NOT match other HN URLs { uri: "https://news.ycombinator.com/", shouldMatch: false }, { uri: "https://news.ycombinator.com/newest", shouldMatch: false }, { uri: "https://news.ycombinator.com/submitted?id=alice", shouldMatch: false }, // Should NOT match without id parameter { uri: "https://news.ycombinator.com/user", shouldMatch: false }, { uri: "https://news.ycombinator.com/user?", shouldMatch: false }, { uri: "https://news.ycombinator.com/user?id=", shouldMatch: false }, { uri: "https://news.ycombinator.com/item", shouldMatch: false }, { uri: "https://news.ycombinator.com/item?", shouldMatch: false }, // Should NOT match with extra parameters { uri: "https://news.ycombinator.com/user?id=alice&foo=bar", shouldMatch: false }, { uri: "https://news.ycombinator.com/item?id=123&foo=bar", shouldMatch: false }, // Wrong domain { uri: "https://reddit.com/user?id=alice", shouldMatch: false }, { uri: "https://example.com/item?id=123", shouldMatch: false }, ], }; export default hackernews;