diff --git a/docs/mint.json b/docs/mint.json index caa762e..9a4d309 100644 --- a/docs/mint.json +++ b/docs/mint.json @@ -83,6 +83,7 @@ "tools/polygon", "tools/predict-leads", "tools/proxycurl", + "tools/reddit", "tools/rocketreach", "tools/searxng", "tools/serpapi", diff --git a/docs/tools/reddit.mdx b/docs/tools/reddit.mdx new file mode 100644 index 0000000..44980eb --- /dev/null +++ b/docs/tools/reddit.mdx @@ -0,0 +1,45 @@ +--- +title: Reddit +description: Basic readonly Reddit API for getting top/hot/new/rising posts from subreddits. +--- + +- package: `@agentic/reddit` +- exports: `class RedditClient`, `namespace reddit` +- [source](https://github.com/transitive-bullshit/agentic/blob/main/packages/reddit/src/reddit-client.ts) +- [reddit api docs](https://old.reddit.com/dev/api) + + + This client uses Reddit's free, publicly accessible legacy JSON API aimed at + RSS feeds, so no API key or auth is required. With that being said, Reddit + does impose rate limits on the API, so be considerate. + + +## Install + + +```bash npm +npm install @agentic/reddit +``` + +```bash yarn +yarn add @agentic/reddit +``` + +```bash pnpm +pnpm add @agentic/reddit +``` + + + +## Usage + +```ts +import { RedditClient } from '@agentic/reddit' + +const reddit = new RedditClient() +const result = await reddit.getSubredditPosts({ + subreddit: 'AskReddit', + type: 'hot', + limit: 10 +}) +``` diff --git a/packages/reddit/package.json b/packages/reddit/package.json new file mode 100644 index 0000000..f9a1c43 --- /dev/null +++ b/packages/reddit/package.json @@ -0,0 +1,45 @@ +{ + "name": "@agentic/reddit", + "version": "0.1.0", + "description": "Agentic SDK for Reddit.", + "author": "Travis Fischer ", + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/transitive-bullshit/agentic.git", + "directory": "packages/reddit" + }, + "type": "module", + "source": "./src/index.ts", + "types": "./dist/index.d.ts", + "sideEffects": false, + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js", + "default": "./dist/index.js" + } + }, + "files": [ + "dist" + ], + "scripts": { + "build": "tsup", + "dev": "tsup --watch", + "clean": "del dist", + "test": "run-s test:*", + "test:lint": "eslint .", + "test:typecheck": "tsc --noEmit" + }, + "dependencies": { + "@agentic/core": "workspace:*", + "ky": "catalog:", + "p-throttle": "catalog:" + }, + "peerDependencies": { + "zod": "catalog:" + }, + "publishConfig": { + "access": "public" + } +} diff --git a/packages/reddit/readme.md b/packages/reddit/readme.md new file mode 100644 index 0000000..38781f3 --- /dev/null +++ b/packages/reddit/readme.md @@ -0,0 +1,24 @@ +

+ + Agentic + +

+ +

+ AI agent stdlib that works with any LLM and TypeScript AI SDK. +

+ +

+ Build Status + NPM + MIT License + Prettier Code Formatting +

+ +# Agentic + +**See the [github repo](https://github.com/transitive-bullshit/agentic) or [docs](https://agentic.so) for more info.** + +## License + +MIT © [Travis Fischer](https://x.com/transitive_bs) diff --git a/packages/reddit/src/index.ts b/packages/reddit/src/index.ts new file mode 100644 index 0000000..357327b --- /dev/null +++ b/packages/reddit/src/index.ts @@ -0,0 +1 @@ +export * from './reddit-client' diff --git a/packages/reddit/src/reddit-client.ts b/packages/reddit/src/reddit-client.ts new file mode 100644 index 0000000..92fdfa9 --- /dev/null +++ b/packages/reddit/src/reddit-client.ts @@ -0,0 +1,452 @@ +import { + aiFunction, + AIFunctionsProvider, + pick, + sanitizeSearchParams +} from '@agentic/core' +import defaultKy, { type KyInstance } from 'ky' +import { z } from 'zod' + +export namespace reddit { + export const BASE_URL = 'https://www.reddit.com' + + export interface Post { + id: string + name: string // name is `t3_` + title: string + subreddit: string + selftext?: string + author: string + author_fullname: string + url: string + permalink: string + thumbnail?: string + thumbnail_width?: number + thumbnail_height?: number + score: number + ups: number + downs: number + num_comments: number + created_utc: number + is_self: boolean + is_video: boolean + } + + export interface FullPost { + id: string + name: string + author: string + title: string + subreddit: string + subreddit_name_prefixed: string + score: number + approved_at_utc: string | null + selftext?: string + author_fullname: string + is_self: boolean + saved: boolean + url: string + permalink: string + mod_reason_title: string | null + gilded: number + clicked: boolean + link_flair_richtext: any[] + hidden: boolean + pwls: number + link_flair_css_class: string + downs: number + thumbnail_height: any + top_awarded_type: any + hide_score: boolean + quarantine: boolean + link_flair_text_color: string + upvote_ratio: number + author_flair_background_color: any + subreddit_type: string + ups: number + total_awards_received: number + media_embed?: any + secure_media_embed?: any + thumbnail_width: any + author_flair_template_id: any + is_original_content: boolean + user_reports: any[] + secure_media: any + is_reddit_media_domain: boolean + is_meta: boolean + category: any + link_flair_text: string + can_mod_post: boolean + approved_by: any + is_created_from_ads_ui: boolean + author_premium: boolean + thumbnail?: string + edited: boolean + author_flair_css_class: any + author_flair_richtext: any[] + gildings?: any + content_categories: any + mod_note: any + created: number + link_flair_type: string + wls: number + removed_by_category: any + banned_by: any + author_flair_type: string + domain: string + allow_live_comments: boolean + selftext_html: string + likes: any + suggested_sort: any + banned_at_utc: any + view_count: any + archived: boolean + no_follow: boolean + is_crosspostable: boolean + pinned: boolean + over_18: boolean + all_awardings: any[] + awarders: any[] + media_only: boolean + link_flair_template_id: string + can_gild: boolean + spoiler: boolean + locked: boolean + author_flair_text: any + treatment_tags: any[] + visited: boolean + removed_by: any + num_reports: any + distinguished: any + subreddit_id: string + author_is_blocked: boolean + mod_reason_by: any + removal_reason: any + link_flair_background_color: string + is_robot_indexable: boolean + report_reasons: any + discussion_type: any + num_comments: number + send_replies: boolean + contest_mode: boolean + mod_reports: any[] + author_patreon_flair: boolean + author_flair_text_color: any + stickied: boolean + subreddit_subscribers: number + created_utc: number + num_crossposts: number + media?: any + is_video: boolean + + // preview images + preview?: { + enabled: boolean + images: Array<{ + id: string + source: Image + resolutions: Image[] + variants?: Record< + string, + { + id: string + source: Image + resolutions: Image[] + } + > + }> + } + } + + export interface Image { + url: string + width: number + height: number + } + + export interface PostT3 { + kind: 't3' + data: FullPost + } + + export interface PostListingResponse { + kind: 'Listing' + data: { + after: string + dist: number + modhash: string + geo_filter?: null + children: PostT3[] + } + before?: null + } + + export type PostFilter = 'hot' | 'top' | 'new' | 'rising' + + export type GeoFilter = + | 'GLOBAL' + | 'US' + | 'AR' + | 'AU' + | 'BG' + | 'CA' + | 'CL' + | 'CO' + | 'HR' + | 'CZ' + | 'FI' + | 'FR' + | 'DE' + | 'GR' + | 'HU' + | 'IS' + | 'IN' + | 'IE' + | 'IT' + | 'JP' + | 'MY' + | 'MX' + | 'NZ' + | 'PH' + | 'PL' + | 'PT' + | 'PR' + | 'RO' + | 'RS' + | 'SG' + | 'ES' + | 'SE' + | 'TW' + | 'TH' + | 'TR' + | 'GB' + | 'US_WA' + | 'US_DE' + | 'US_DC' + | 'US_WI' + | 'US_WV' + | 'US_HI' + | 'US_FL' + | 'US_WY' + | 'US_NH' + | 'US_NJ' + | 'US_NM' + | 'US_TX' + | 'US_LA' + | 'US_NC' + | 'US_ND' + | 'US_NE' + | 'US_TN' + | 'US_NY' + | 'US_PA' + | 'US_CA' + | 'US_NV' + | 'US_VA' + | 'US_CO' + | 'US_AK' + | 'US_AL' + | 'US_AR' + | 'US_VT' + | 'US_IL' + | 'US_GA' + | 'US_IN' + | 'US_IA' + | 'US_OK' + | 'US_AZ' + | 'US_ID' + | 'US_CT' + | 'US_ME' + | 'US_MD' + | 'US_MA' + | 'US_OH' + | 'US_UT' + | 'US_MO' + | 'US_MN' + | 'US_MI' + | 'US_RI' + | 'US_KS' + | 'US_MT' + | 'US_MS' + | 'US_SC' + | 'US_KY' + | 'US_OR' + | 'US_SD' + + export type TimePeriod = 'hour' | 'day' | 'week' | 'month' | 'year' | 'all' + + export type GetSubredditPostsOptions = { + subreddit: string + type?: PostFilter + + // Pagination size and offset (count) + limit?: number + count?: number + + // Pagination by fullnames of posts + before?: string + after?: string + + /** + * Geographical filter. Only applicable to 'hot' posts. + */ + geo?: GeoFilter + + /** + * Filter by time period. Only applicable to 'top' posts. + */ + time?: TimePeriod + } + + export interface PostListingResult { + subreddit: string + type: PostFilter + geo?: GeoFilter + time?: TimePeriod + posts: Post[] + } +} + +/** + * Basic readonly Reddit API for fetching top/hot/new/rising posts from subreddits. + * + * Uses Reddit's legacy JSON API aimed at RSS feeds. + * + * @see https://old.reddit.com/dev/api + */ +export class RedditClient extends AIFunctionsProvider { + protected readonly ky: KyInstance + protected readonly baseUrl: string + + constructor({ + baseUrl = reddit.BASE_URL, + userAgent = 'agentic-reddit-client/1.0.0', + ky = defaultKy + }: { + baseUrl?: string + userAgent?: string + ky?: KyInstance + } = {}) { + super() + + this.baseUrl = baseUrl + + this.ky = ky.extend({ + prefixUrl: this.baseUrl, + headers: { + 'User-Agent': userAgent + } + }) + } + + /** + * Fetches posts from a subreddit. + * + * @see https://old.reddit.com/dev/api/#GET_hot + */ + @aiFunction({ + name: 'reddit_get_subreddit_posts', + description: 'Fetches posts from a subreddit.', + inputSchema: z.object({ + subreddit: z.string().describe('The subreddit to fetch posts from.'), + type: z + .union([ + z.literal('hot'), + z.literal('top'), + z.literal('new'), + z.literal('rising') + ]) + .optional() + .describe('Type of posts to fetch (defaults to "hot").'), + limit: z + .number() + .int() + .max(100) + .optional() + .describe('Max number of posts to return (defaults to 5).'), + count: z + .number() + .int() + .optional() + .describe('Number of posts to offset by (defaults to 0).'), + time: z + .union([ + z.literal('hour'), + z.literal('day'), + z.literal('week'), + z.literal('month'), + z.literal('year'), + z.literal('all') + ]) + .optional() + .describe( + 'Time period to filter posts by (defaults to "all"). Only applicable to "top" posts type.' + ) + }) + }) + async getSubredditPosts( + subredditOrOpts: string | reddit.GetSubredditPostsOptions + ): Promise { + const params = + typeof subredditOrOpts === 'string' + ? { subreddit: subredditOrOpts } + : subredditOrOpts + const { subreddit, type = 'hot', limit = 5, geo, time, ...opts } = params + + const res = await this.ky + .get(`r/${subreddit}/${type}.json`, { + searchParams: sanitizeSearchParams({ + ...opts, + limit, + g: type === 'hot' ? geo : undefined, + t: type === 'top' ? time : undefined + }) + }) + .json() + + return { + subreddit, + type, + geo: type === 'hot' ? geo : undefined, + time: type === 'top' ? time : undefined, + posts: res.data.children.map((child) => { + const post = child.data + + // Trim the post data to only include the bare minimum + // TODO: add preview images + // TODO: add video media info + return { + ...pick( + post, + 'id', + 'name', + 'title', + 'subreddit', + 'selftext', + 'author', + 'author_fullname', + 'url', + 'permalink', + 'thumbnail', + 'thumbnail_width', + 'thumbnail_height', + 'score', + 'ups', + 'downs', + 'num_comments', + 'created_utc', + 'is_self', + 'is_video' + ), + permalink: `${this.baseUrl}${post.permalink}`, + thumbnail: + post.thumbnail !== 'self' && + post.thumbnail !== 'default' && + post.thumbnail !== 'spoiler' && + post.thumbnail !== 'nsfw' + ? post.thumbnail + : undefined + } + }) + } + } +} diff --git a/packages/reddit/tsconfig.json b/packages/reddit/tsconfig.json new file mode 100644 index 0000000..51348fa --- /dev/null +++ b/packages/reddit/tsconfig.json @@ -0,0 +1,5 @@ +{ + "extends": "@fisch0920/config/tsconfig-node", + "include": ["src"], + "exclude": ["node_modules", "dist"] +} diff --git a/packages/stdlib/package.json b/packages/stdlib/package.json index d4f0d5d..57ef6cf 100644 --- a/packages/stdlib/package.json +++ b/packages/stdlib/package.json @@ -62,6 +62,7 @@ "@agentic/polygon": "workspace:*", "@agentic/predict-leads": "workspace:*", "@agentic/proxycurl": "workspace:*", + "@agentic/reddit": "workspace:*", "@agentic/rocketreach": "workspace:*", "@agentic/searxng": "workspace:*", "@agentic/serpapi": "workspace:*", diff --git a/packages/stdlib/src/index.ts b/packages/stdlib/src/index.ts index 343f3d8..293a50c 100644 --- a/packages/stdlib/src/index.ts +++ b/packages/stdlib/src/index.ts @@ -27,6 +27,7 @@ export * from '@agentic/perigon' export * from '@agentic/polygon' export * from '@agentic/predict-leads' export * from '@agentic/proxycurl' +export * from '@agentic/reddit' export * from '@agentic/rocketreach' export * from '@agentic/searxng' export * from '@agentic/serpapi' diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7e58fbc..6388108 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -976,6 +976,21 @@ importers: specifier: 'catalog:' version: 3.24.2 + packages/reddit: + dependencies: + '@agentic/core': + specifier: workspace:* + version: link:../core + ky: + specifier: 'catalog:' + version: 1.8.0 + p-throttle: + specifier: 'catalog:' + version: 6.2.0 + zod: + specifier: 'catalog:' + version: 3.24.2 + packages/rocketreach: dependencies: '@agentic/core': @@ -1146,6 +1161,9 @@ importers: '@agentic/proxycurl': specifier: workspace:* version: link:../proxycurl + '@agentic/reddit': + specifier: workspace:* + version: link:../reddit '@agentic/rocketreach': specifier: workspace:* version: link:../rocketreach @@ -5063,7 +5081,6 @@ packages: libsql@0.4.7: resolution: {integrity: sha512-T9eIRCs6b0J1SHKYIvD8+KCJMcWZ900iZyxdnSCdqxN12Z1ijzT+jY5nrk72Jw4B0HGzms2NgpryArlJqvc3Lw==} - cpu: [x64, arm64, wasm32] os: [darwin, linux, win32] lilconfig@3.1.3: diff --git a/readme.md b/readme.md index d96f56e..97cabca 100644 --- a/readme.md +++ b/readme.md @@ -208,6 +208,7 @@ Full docs are available at [agentic.so](https://agentic.so). | [Polygon](https://polygon.io) | `@agentic/polygon` | [docs](https://agentic.so/tools/polygon) | Stock market and company financial data. | | [PredictLeads](https://predictleads.com) | `@agentic/predict-leads` | [docs](https://agentic.so/tools/predict-leads) | In-depth company data including signals like fundraising events, hiring news, product launches, technologies used, etc. | | [Proxycurl](https://nubela.co/proxycurl) | `@agentic/proxycurl` | [docs](https://agentic.so/tools/proxycurl) | People and company data from LinkedIn & Crunchbase. | +| [Reddit](https://old.reddit.com/dev/api) | `@agentic/reddit` | [docs](https://agentic.so/tools/reddit) | Basic readonly Reddit API for getting top/hot/new/rising posts from subreddits. | | [RocketReach](https://rocketreach.co/api/v2/docs) | `@agentic/rocketreach` | [docs](https://agentic.so/tools/rocketreach) | B2B person and company enrichment API. | | [Searxng](https://docs.searxng.org) | `@agentic/searxng` | [docs](https://agentic.so/tools/searxng) | OSS meta search engine capable of searching across many providers like Reddit, Google, Brave, Arxiv, Genius, IMDB, Rotten Tomatoes, Wikidata, Wolfram Alpha, YouTube, GitHub, [etc](https://docs.searxng.org/user/configured_engines.html#configured-engines). | | [SerpAPI](https://serpapi.com/search-api) | `@agentic/serpapi` | [docs](https://agentic.so/tools/serpapi) | Lightweight wrapper around SerpAPI for Google search. |