2025-02-28 05:30:16 +00:00
import {
aiFunction ,
AIFunctionsProvider ,
assert ,
getEnv ,
sanitizeSearchParams
} from '@agentic/core'
2024-07-01 06:12:49 +00:00
import defaultKy , { type KyInstance } from 'ky'
2025-04-03 15:54:40 +00:00
import { z } from 'zod'
2024-07-01 06:12:49 +00:00
export namespace hackernews {
2025-02-28 05:30:16 +00:00
export const HACKER_NEWS_API_BASE_URL = 'https://hacker-news.firebaseio.com'
export const HACKER_NEWS_API_SEARCH_BASE_URL = 'https://hn.algolia.com'
export const HACKER_NEWS_API_USER_AGENT =
'Agentic (https://github.com/transitive-bullshit/agentic)'
2024-07-01 06:12:49 +00:00
export type ItemType =
| 'story'
| 'comment'
| 'ask'
| 'job'
| 'poll'
| 'pollopt'
export interface Item {
id : number
type : ItemType
by : string
time : number
score : number
title? : string
url? : string
text? : string
descendants? : number
parent? : number
kids? : number [ ]
parts? : number [ ]
}
export interface User {
id : string
created : number
about : string
karma : number
submitted : number [ ]
}
2025-02-28 05:30:16 +00:00
export type SearchTag =
| 'story'
| 'comment'
| 'poll'
| 'pollopt'
| 'show_hn'
| 'ask_hn'
| 'front_page'
export type SearchNumericFilterField =
| 'created_at_i'
| 'points'
| 'num_comments'
export type SearchNumericFilterCondition = '<' | '<=' | '=' | '>=' | '>'
export type SearchSortBy = 'relevance' | 'recency'
export interface SearchOptions {
/** Full-text search query */
query? : string
/** Filter by author's HN username */
author? : string
/** Filter by story id */
story? : string
/** Filter by type of item (story, comment, etc.) */
tags? : Array < SearchTag >
/** Filter by numeric range (created_at_i, points, or num_comments); (created_at_i is a timestamp in seconds) */
numericFilters? : Array < ` ${ SearchNumericFilterField } ${ SearchNumericFilterCondition } ${ number } ` >
/** Page number to return */
page? : number
/** Number of results to return per page */
hitsPerPage? : number
/** How to sort the results */
sortBy? : SearchSortBy
}
export interface SearchItem {
id : number
created_at : string
created_at_i : number
title? : string
url? : string
author : string
text : string | null
points : number | null
parent_id : number | null
story_id : number | null
type : ItemType
children : SearchItem [ ]
options? : any [ ]
}
export interface SearchUser {
username : string
about : string
karma : number
}
export interface SearchResponse {
hits : SearchHit [ ]
page : number
nbHits : number
nbPages : number
hitsPerPage : number
query : string
params : string
processingTimeMS : number
serverTimeMS : number
processingTimingsMS? : any
}
export interface SearchHit {
objectID : string
url : string
title : string
author : string
story_text? : string
story_id? : number
story_url? : string
comment_text? : string
points? : number
num_comments? : number
created_at : string
created_at_i : number
updated_at : string
parts? : number [ ]
children : number [ ]
_tags : string [ ]
_highlightResult : SearchHighlightResult
}
export interface SearchHighlightResult {
author : Highlight
title? : Highlight
url? : Highlight
comment_text? : Highlight
story_title? : Highlight
story_url? : Highlight
}
export interface Highlight {
value : string
matchLevel : string
matchedWords : string [ ]
fullyHighlighted? : boolean
}
export const searchTagSchema = z . union ( [
z . literal ( 'story' ) ,
z . literal ( 'comment' ) ,
z . literal ( 'poll' ) ,
z . literal ( 'pollopt' ) ,
z . literal ( 'show_hn' ) ,
z . literal ( 'ask_hn' ) ,
z . literal ( 'front_page' )
] )
export const searchSortBySchema = z . union ( [
z . literal ( 'relevance' ) ,
z . literal ( 'recency' )
] )
export const searchOptionsSchema = z . object ( {
query : z.string ( ) . optional ( ) . describe ( 'Full-text search query' ) ,
author : z.string ( ) . optional ( ) . describe ( "Filter by author's HN username" ) ,
story : z.string ( ) . optional ( ) . describe ( 'Filter by story id' ) ,
tags : z
. array ( hackernews . searchTagSchema )
. optional ( )
. describe (
"Filter by type of item (story, comment, etc.). Multiple tags are AND'ed together."
) ,
numericFilters : z
. array ( z . any ( ) )
. optional ( )
. describe (
'Filter by numeric range (created_at_i, points, or num_comments); (created_at_i is a timestamp in seconds). Ex: numericFilters=points>100,num_comments>=1000'
) ,
2025-03-31 05:53:24 +00:00
page : z.number ( ) . int ( ) . optional ( ) . describe ( 'Page number to return' ) ,
2025-02-28 05:30:16 +00:00
hitsPerPage : z
. number ( )
. int ( )
. optional ( )
. describe ( 'Number of results to return per page (defaults to 50)' ) ,
sortBy : hackernews.searchSortBySchema
. optional ( )
. describe ( 'How to sort the results (defaults to relevancy)' )
} )
2024-07-01 06:12:49 +00:00
}
/ * *
* Basic client for the official Hacker News API .
*
2025-02-28 05:30:16 +00:00
* The normal API methods ( ` getItem ` ) use the official Firebase API , while the
* search - prefixed methods use the more powerful Algolia API . The tradeoff is
* that the official Firebase API is generally more reliable in my experience ,
* which is why we opted to support both .
2024-07-01 06:12:49 +00:00
*
* @see https : //github.com/HackerNews/API
2025-02-28 05:30:16 +00:00
* @see https : //hn.algolia.com/api
2024-07-01 06:12:49 +00:00
* /
export class HackerNewsClient extends AIFunctionsProvider {
2025-02-28 05:30:16 +00:00
protected readonly apiKy : KyInstance
protected readonly apiSearchKy : KyInstance
2024-07-01 06:12:49 +00:00
protected readonly apiBaseUrl : string
2025-02-28 05:30:16 +00:00
protected readonly apiSearchBaseUrl : string
2024-07-01 06:12:49 +00:00
protected readonly apiUserAgent : string
constructor ( {
apiBaseUrl = getEnv ( 'HACKER_NEWS_API_BASE_URL' ) ? ?
2025-02-28 05:30:16 +00:00
hackernews . HACKER_NEWS_API_BASE_URL ,
apiSearchBaseUrl = getEnv ( 'HACKER_NEWS_API_SEARCH_BASE_URL' ) ? ?
hackernews . HACKER_NEWS_API_SEARCH_BASE_URL ,
2024-07-01 06:12:49 +00:00
apiUserAgent = getEnv ( 'HACKER_NEWS_API_USER_AGENT' ) ? ?
2025-02-28 05:30:16 +00:00
hackernews . HACKER_NEWS_API_USER_AGENT ,
ky = defaultKy ,
timeoutMs = 60 _000
2024-07-01 06:12:49 +00:00
} : {
apiBaseUrl? : string
2025-02-28 05:30:16 +00:00
apiSearchBaseUrl? : string
2024-07-01 06:12:49 +00:00
apiUserAgent? : string
ky? : KyInstance
2025-02-28 05:30:16 +00:00
timeoutMs? : number
2024-07-01 06:12:49 +00:00
} = { } ) {
assert ( apiBaseUrl , 'HackerNewsClient missing required "apiBaseUrl"' )
2025-02-28 05:30:16 +00:00
assert (
apiSearchBaseUrl ,
'HackerNewsClient missing required "apiSearchBaseUrl"'
)
2024-07-01 06:12:49 +00:00
super ( )
this . apiBaseUrl = apiBaseUrl
2025-02-28 05:30:16 +00:00
this . apiSearchBaseUrl = apiSearchBaseUrl
2024-07-01 06:12:49 +00:00
this . apiUserAgent = apiUserAgent
2025-02-28 05:30:16 +00:00
this . apiKy = ky . extend ( {
2024-07-01 06:12:49 +00:00
prefixUrl : apiBaseUrl ,
2025-02-28 05:30:16 +00:00
timeout : timeoutMs ,
headers : {
'user-agent' : apiUserAgent
}
} )
this . apiSearchKy = ky . extend ( {
prefixUrl : apiSearchBaseUrl ,
timeout : timeoutMs ,
2024-07-01 06:12:49 +00:00
headers : {
'user-agent' : apiUserAgent
}
} )
}
2025-02-28 11:30:39 +00:00
/** Fetches a HN story or comment by its ID. */
2025-02-28 05:30:16 +00:00
@aiFunction ( {
name : 'hacker_news_get_item' ,
description : 'Fetches a HN story or comment by its ID.' ,
inputSchema : z.object ( { itemId : z.string ( ) } )
} )
async getSearchItem ( itemIdOrOpts : string | number | { itemId : string } ) {
const { itemId } =
typeof itemIdOrOpts === 'string' || typeof itemIdOrOpts === 'number'
? { itemId : itemIdOrOpts }
: itemIdOrOpts
return this . apiSearchKy
. get ( ` api/v1/items/ ${ itemId } ` )
. json < hackernews.SearchItem > ( )
}
2025-02-28 11:30:39 +00:00
/ * *
* Fetches a HN user by username .
* /
2025-02-28 05:30:16 +00:00
@aiFunction ( {
name : 'hacker_news_get_user' ,
description : 'Fetches a HN user by username.' ,
inputSchema : z.object ( { username : z.string ( ) } )
} )
async getSearchUser ( usernameOrOpts : string | number | { username : string } ) {
const { username } =
typeof usernameOrOpts === 'string' || typeof usernameOrOpts === 'number'
? { username : usernameOrOpts }
: usernameOrOpts
return this . apiSearchKy
. get ( ` api/v1/users/ ${ username } ` )
. json < hackernews.SearchUser > ( )
}
2025-02-28 11:30:39 +00:00
/** Searches HN for stories and comments matching the given query. */
2025-02-28 05:30:16 +00:00
@aiFunction ( {
name : 'hacker_news_search' ,
description :
'Searches HN for stories and comments matching the given query.' ,
inputSchema : hackernews.searchOptionsSchema
} )
async searchItems ( queryOrOpts : string | hackernews . SearchOptions ) {
const {
query ,
numericFilters ,
page ,
hitsPerPage ,
sortBy = 'relevance' ,
. . . opts
} = typeof queryOrOpts === 'string' ? { query : queryOrOpts } : queryOrOpts
// Tags are AND'ed together; we do not support OR'ing tags via parentheses.
const tags = [
. . . ( opts . tags ? ? [ ] ) ,
opts . story ? ` story_ ${ opts . story } ` : undefined ,
opts . author ? ` author_ ${ opts . author } ` : undefined
] . filter ( Boolean )
return this . apiSearchKy
. get ( sortBy === 'relevance' ? 'api/v1/search' : 'api/v1/search_by_date' , {
searchParams : sanitizeSearchParams (
{
query ,
tags ,
numericFilters ,
page ,
hitsPerPage
} ,
{ csv : true }
)
} )
. json < hackernews.SearchResponse > ( )
}
2025-02-28 11:30:39 +00:00
/ * *
* Fetches / searches the top stories currently on the front page of HN . This is the same as ` hacker_news_search ` , but with ` tags: ["front_page"] ` set to filter only by the current front page stories .
* /
2025-02-28 05:30:16 +00:00
@aiFunction ( {
name : 'hacker_news_get_top_stories' ,
description :
'Fetches / searches the top stories currently on the front page of HN. This is the same as `hacker_news_search`, but with `tags: ["front_page"]` set to filter only by the current front page stories.' ,
inputSchema : hackernews.searchOptionsSchema
} )
async getSearchTopStories ( queryOrOpts : string | hackernews . SearchOptions ) {
const opts =
typeof queryOrOpts === 'string' ? { query : queryOrOpts } : queryOrOpts
return this . searchItems ( {
. . . opts ,
tags : [ 'front_page' , . . . ( opts . tags ? ? [ ] ) ]
} )
}
2024-07-01 06:12:49 +00:00
async getItem ( id : string | number ) {
2025-02-28 05:30:16 +00:00
return this . apiKy . get ( ` v0/item/ ${ id } .json ` ) . json < hackernews.Item > ( )
2024-07-01 06:12:49 +00:00
}
async getTopStories() {
2025-02-28 05:30:16 +00:00
return this . apiKy . get ( 'v0/topstories.json' ) . json < number [ ] > ( )
2024-07-01 06:12:49 +00:00
}
async getNewStories() {
2025-02-28 05:30:16 +00:00
return this . apiKy . get ( 'v0/newstories.json' ) . json < number [ ] > ( )
2024-07-01 06:12:49 +00:00
}
async getBestStories() {
2025-02-28 05:30:16 +00:00
return this . apiKy . get ( 'v0/beststories.json' ) . json < number [ ] > ( )
2024-07-01 06:12:49 +00:00
}
}