public-api-finder 0.1.2 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -2,7 +2,11 @@
2
2
 
3
3
  Find free/public APIs for agents, prototypes, demos, and integrations.
4
4
 
5
- Powered by the curated [`public-api-lists/public-api-lists`](https://github.com/public-api-lists/public-api-lists) JSON dataset.
5
+ Powered by multiple sources:
6
+
7
+ - [`public-api-lists/public-api-lists`](https://github.com/public-api-lists/public-api-lists) for fast curated JSON discovery
8
+ - [`public-apis/public-apis`](https://github.com/public-apis/public-apis) for the larger canonical README list
9
+ - [`APIs-guru/openapi-directory`](https://github.com/APIs-guru/openapi-directory) for OpenAPI-backed APIs
6
10
 
7
11
  ## Quick start
8
12
 
@@ -10,6 +14,7 @@ Powered by the curated [`public-api-lists/public-api-lists`](https://github.com/
10
14
  npx public-api-finder "weather forecast" --no-auth --https
11
15
  npx public-api-finder "crypto prices" --category Cryptocurrency --limit 5
12
16
  npx public-api-finder "jobs" --json
17
+ npx public-api-finder "payments" --openapi
13
18
  ```
14
19
 
15
20
  ## Why
@@ -30,9 +35,11 @@ The skill tells agents to prefer the CLI first, then live-check docs/endpoints b
30
35
 
31
36
  ```text
32
37
  --category <name> Filter by category substring
38
+ --source <name> Filter by source: public-api-lists, public-apis, apis-guru
33
39
  --no-auth Only APIs with Auth = No
34
40
  --https Only HTTPS APIs
35
41
  --cors <value> Filter by CORS: Yes, No, Unknown
42
+ --openapi Only APIs with OpenAPI specs
36
43
  --limit <n> Max results
37
44
  --json Emit JSON
38
45
  --refresh Refresh cache
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "public-api-finder",
3
- "version": "0.1.2",
3
+ "version": "0.2.1",
4
4
  "description": "Find free/public APIs for agents and prototypes.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -5,7 +5,7 @@ description: Find and evaluate free/public APIs for projects, demos, agents, pro
5
5
 
6
6
  # Public API Finder
7
7
 
8
- Use this skill when a task needs a public API candidate. The agent-friendly path is the CLI first, then live-check docs/endpoints before coding.
8
+ Use this skill when a task needs a public API candidate. The CLI searches multiple sources: public-api-lists, public-apis, and APIs.guru OpenAPI directory. Use the CLI first, then live-check docs/endpoints before coding.
9
9
 
10
10
  ## Quick command
11
11
 
@@ -13,6 +13,7 @@ Use this skill when a task needs a public API candidate. The agent-friendly path
13
13
  npx public-api-finder "weather forecast" --no-auth --https
14
14
  npx public-api-finder "crypto prices" --category Cryptocurrency --limit 5
15
15
  npx public-api-finder "jobs" --json
16
+ npx public-api-finder "payments" --openapi
16
17
  ```
17
18
 
18
19
  If npm is unavailable, use the bundled fallback script:
@@ -33,6 +34,7 @@ Recommend 2-5 APIs. Include:
33
34
  - HTTPS/CORS notes
34
35
  - One caveat to verify: rate limits, pricing, docs freshness, uptime, or terms
35
36
  - Minimal example request only after checking docs/live endpoint
37
+ - OpenAPI URL when available
36
38
 
37
39
  ## Heuristics
38
40
 
package/src/cli.js CHANGED
@@ -3,21 +3,27 @@ import { mkdir, readFile, stat, writeFile } from 'node:fs/promises';
3
3
  import { homedir } from 'node:os';
4
4
  import { dirname, join } from 'node:path';
5
5
 
6
- const SOURCE_URL = 'https://public-api-lists.github.io/public-api-lists/api/all.json';
6
+ const SOURCES = {
7
+ publicApiLists: 'https://public-api-lists.github.io/public-api-lists/api/all.json',
8
+ publicApisReadme: 'https://raw.githubusercontent.com/public-apis/public-apis/master/README.md',
9
+ apisGuru: 'https://api.apis.guru/v2/list.json',
10
+ };
7
11
  const CACHE_PATH = process.env.PUBLIC_API_FINDER_CACHE || join(homedir(), '.cache', 'public-api-finder', 'all.json');
8
12
  const CACHE_TTL_MS = 24 * 60 * 60 * 1000;
9
13
 
10
14
  function usage() {
11
- console.log(`public-api-finder — find public APIs for agents and prototypes
15
+ console.log(`public-api-finder — multi-source public API discovery for agents
12
16
 
13
17
  Usage:
14
18
  public-api-finder <query> [options]
15
19
 
16
20
  Options:
17
21
  --category <name> Filter by category substring
22
+ --source <name> Filter by source: public-api-lists, public-apis, apis-guru
18
23
  --no-auth Only APIs with Auth = No
19
24
  --https Only HTTPS APIs
20
25
  --cors <value> Filter by CORS: Yes, No, Unknown
26
+ --openapi Only APIs with OpenAPI specs
21
27
  --limit <n> Max results (default: 8)
22
28
  --json Emit JSON
23
29
  --refresh Refresh cache
@@ -26,7 +32,7 @@ Options:
26
32
  Examples:
27
33
  public-api-finder "weather forecast" --no-auth --https
28
34
  public-api-finder "crypto prices" --category Cryptocurrency --limit 5
29
- public-api-finder "jobs" --json
35
+ public-api-finder "payments" --openapi --json
30
36
  `);
31
37
  }
32
38
 
@@ -40,7 +46,9 @@ function parseArgs(argv) {
40
46
  else if (a === '--https') args.https = true;
41
47
  else if (a === '--json') args.json = true;
42
48
  else if (a === '--refresh') args.refresh = true;
49
+ else if (a === '--openapi') args.openapi = true;
43
50
  else if (a === '--category') args.category = argv[++i] || '';
51
+ else if (a === '--source') args.source = argv[++i] || '';
44
52
  else if (a === '--cors') args.cors = argv[++i] || '';
45
53
  else if (a === '--limit') args.limit = Number(argv[++i] || 8);
46
54
  else parts.push(a);
@@ -59,17 +67,26 @@ function intersectionCount(a, b) {
59
67
  return n;
60
68
  }
61
69
 
62
- function score(entry, queryTokens) {
70
+ function textScore(entry, queryTokens) {
63
71
  const name = tokenSet(entry.name);
64
72
  const category = tokenSet(entry.category);
65
73
  const desc = tokenSet(entry.description);
66
- const all = new Set([...name, ...category, ...desc]);
74
+ const all = new Set([...name, ...category, ...desc, ...tokenSet(entry.provider || '')]);
67
75
  return 5 * intersectionCount(queryTokens, name)
68
76
  + 4 * intersectionCount(queryTokens, category)
69
77
  + 2 * intersectionCount(queryTokens, desc)
70
78
  + intersectionCount(queryTokens, all);
71
79
  }
72
80
 
81
+ function score(entry, queryTokens) {
82
+ let base = textScore(entry, queryTokens);
83
+ if (entry.openapiUrl) base += 2;
84
+ if (entry.sources?.length > 1) base += 2;
85
+ if (entry.auth === 'No') base += 1;
86
+ if (entry.https) base += 1;
87
+ return base;
88
+ }
89
+
73
90
  async function cacheIsFresh() {
74
91
  try {
75
92
  const s = await stat(CACHE_PATH);
@@ -79,28 +96,190 @@ async function cacheIsFresh() {
79
96
  }
80
97
  }
81
98
 
99
+ async function fetchJson(url) {
100
+ const res = await fetch(url, { headers: { 'user-agent': 'public-api-finder/0.2' } });
101
+ if (!res.ok) throw new Error(`HTTP ${res.status} for ${url}`);
102
+ return res.json();
103
+ }
104
+
105
+ async function fetchText(url) {
106
+ const res = await fetch(url, { headers: { 'user-agent': 'public-api-finder/0.2' } });
107
+ if (!res.ok) throw new Error(`HTTP ${res.status} for ${url}`);
108
+ return res.text();
109
+ }
110
+
111
+ function normalizeCategory(cat) {
112
+ if (!cat) return 'Unknown';
113
+ return String(cat).replace(/-/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
114
+ }
115
+
116
+ function cleanDescription(desc) {
117
+ return String(desc || '')
118
+ .replace(/<[^>]+>/g, ' ')
119
+ .replace(/[#*_`>\[\]()]/g, '')
120
+ .replace(/\s+/g, ' ')
121
+ .trim()
122
+ .slice(0, 260).replace(/\s+\S{0,20}$/, '');
123
+ }
124
+
125
+ function normalizeAuth(auth) {
126
+ const a = String(auth || 'Unknown').replace(/`/g, '').trim();
127
+ if (/^no$/i.test(a)) return 'No';
128
+ if (/api\s*key/i.test(a)) return 'apiKey';
129
+ if (/oauth/i.test(a)) return 'OAuth';
130
+ return a || 'Unknown';
131
+ }
132
+
133
+ function parsePublicApisReadme(readme) {
134
+ const entries = [];
135
+ let category = '';
136
+ for (const raw of readme.split('\n')) {
137
+ const heading = raw.match(/^###\s+(.+)/);
138
+ if (heading) {
139
+ category = heading[1].trim();
140
+ continue;
141
+ }
142
+ if (!raw.startsWith('| [')) continue;
143
+ const cells = raw.split('|').slice(1, -1).map(c => c.trim());
144
+ if (cells.length < 5) continue;
145
+ const link = cells[0].match(/\[([^\]]+)\]\(([^)]+)\)/);
146
+ if (!link) continue;
147
+ entries.push({
148
+ name: link[1],
149
+ url: link[2],
150
+ description: cleanDescription(cells[1]),
151
+ auth: normalizeAuth(cells[2]),
152
+ https: /^yes$/i.test(cells[3]),
153
+ cors: /^(yes|no|unknown)$/i.test(cells[4]) ? normalizeCategory(cells[4]) : 'Unknown',
154
+ category,
155
+ source: 'public-apis',
156
+ sourceWeight: 2,
157
+ });
158
+ }
159
+ return entries;
160
+ }
161
+
162
+ function parseApisGuru(data) {
163
+ const entries = [];
164
+ for (const [providerName, item] of Object.entries(data || {})) {
165
+ const version = item.versions?.[item.preferred] || Object.values(item.versions || {})[0];
166
+ const info = version?.info || {};
167
+ const origin = info['x-origin']?.[0]?.url;
168
+ entries.push({
169
+ name: info.title || providerName,
170
+ url: (info.contact?.url && !String(info.contact.url).startsWith('file:')) ? info.contact.url : ((origin && !String(origin).startsWith('file:')) ? origin : (version?.swaggerUrl || version?.openapiUrl || `https://api.apis.guru/v2/specs/${providerName}/${item.preferred || 'latest'}/openapi.json`)),
171
+ description: cleanDescription(info.description || `OpenAPI definition for ${providerName}`),
172
+ auth: 'Unknown',
173
+ https: true,
174
+ cors: 'Unknown',
175
+ category: normalizeCategory(info['x-apisguru-categories']?.[0] || 'OpenAPI'),
176
+ source: 'apis-guru',
177
+ sourceWeight: 2,
178
+ provider: providerName,
179
+ openapiUrl: version?.swaggerUrl || version?.openapiUrl || origin || null,
180
+ });
181
+ }
182
+ return entries;
183
+ }
184
+
185
+ async function buildData() {
186
+ const [pal, publicApisReadme, guru] = await Promise.allSettled([
187
+ fetchJson(SOURCES.publicApiLists),
188
+ fetchText(SOURCES.publicApisReadme),
189
+ fetchJson(SOURCES.apisGuru),
190
+ ]);
191
+ const entries = [];
192
+ const sourceStatus = {};
193
+ if (pal.status === 'fulfilled') {
194
+ sourceStatus['public-api-lists'] = pal.value.entries?.length || 0;
195
+ entries.push(...(pal.value.entries || []).map(e => ({ ...e, auth: normalizeAuth(e.auth), source: 'public-api-lists', sourceWeight: 1 })));
196
+ } else sourceStatus['public-api-lists'] = `error: ${pal.reason.message}`;
197
+ if (publicApisReadme.status === 'fulfilled') {
198
+ const rows = parsePublicApisReadme(publicApisReadme.value);
199
+ sourceStatus['public-apis'] = rows.length;
200
+ entries.push(...rows);
201
+ } else sourceStatus['public-apis'] = `error: ${publicApisReadme.reason.message}`;
202
+ if (guru.status === 'fulfilled') {
203
+ const rows = parseApisGuru(guru.value);
204
+ sourceStatus['apis-guru'] = rows.length;
205
+ entries.push(...rows);
206
+ } else sourceStatus['apis-guru'] = `error: ${guru.reason.message}`;
207
+ return { generatedAt: new Date().toISOString(), sourceStatus, entries: dedupe(entries) };
208
+ }
209
+
210
+ function keyFor(entry) {
211
+ const host = String(entry.url || '').toLowerCase().replace(/^https?:\/\//, '').replace(/^www\./, '').split('/')[0];
212
+ return `${String(entry.name || '').toLowerCase().replace(/[^a-z0-9]+/g, '')}|${host}`;
213
+ }
214
+
215
+ function mergeEntry(a, b) {
216
+ const sources = new Set([...(a.sources || [a.source]), ...(b.sources || [b.source])].filter(Boolean));
217
+ return {
218
+ ...a,
219
+ description: (b.description || '').length > (a.description || '').length ? b.description : a.description,
220
+ auth: a.auth !== 'Unknown' ? a.auth : b.auth,
221
+ https: Boolean(a.https || b.https),
222
+ cors: a.cors !== 'Unknown' ? a.cors : b.cors,
223
+ category: a.category !== 'Unknown' ? a.category : b.category,
224
+ openapiUrl: a.openapiUrl || b.openapiUrl || null,
225
+ provider: a.provider || b.provider,
226
+ sourceWeight: (a.sourceWeight || 0) + (b.sourceWeight || 0),
227
+ sources: [...sources],
228
+ };
229
+ }
230
+
231
+ function dedupe(entries) {
232
+ const map = new Map();
233
+ for (const e of entries) {
234
+ const clean = {
235
+ name: e.name,
236
+ url: e.url,
237
+ description: cleanDescription(e.description),
238
+ auth: normalizeAuth(e.auth),
239
+ https: Boolean(e.https),
240
+ cors: e.cors || 'Unknown',
241
+ category: normalizeCategory(e.category),
242
+ source: e.source,
243
+ sourceWeight: e.sourceWeight || 1,
244
+ sources: [...(e.sources || []), e.source].filter(Boolean),
245
+ provider: e.provider,
246
+ openapiUrl: e.openapiUrl || null,
247
+ };
248
+ const key = keyFor(clean);
249
+ map.set(key, map.has(key) ? mergeEntry(map.get(key), clean) : clean);
250
+ }
251
+ return [...map.values()];
252
+ }
253
+
82
254
  async function loadData(refresh = false) {
83
255
  if (!refresh && await cacheIsFresh()) {
84
- return JSON.parse(await readFile(CACHE_PATH, 'utf8')).entries || [];
256
+ const cached = JSON.parse(await readFile(CACHE_PATH, 'utf8'));
257
+ return cached.entries || [];
85
258
  }
86
- const res = await fetch(SOURCE_URL);
87
- if (!res.ok) throw new Error(`failed to fetch API list: HTTP ${res.status}`);
88
- const data = await res.json();
259
+ const data = await buildData();
89
260
  await mkdir(dirname(CACHE_PATH), { recursive: true });
90
261
  await writeFile(CACHE_PATH, JSON.stringify(data, null, 2));
91
262
  return data.entries || [];
92
263
  }
93
264
 
265
+ function sourceMatches(entry, source) {
266
+ if (!source) return true;
267
+ return (entry.sources || [entry.source]).some(s => String(s).toLowerCase() === source.toLowerCase());
268
+ }
269
+
94
270
  function filterEntries(entries, args) {
95
271
  const q = tokenSet(args.query);
96
272
  return entries.flatMap(e => {
97
273
  if (args.category && !String(e.category || '').toLowerCase().includes(args.category.toLowerCase())) return [];
274
+ if (args.source && !sourceMatches(e, args.source)) return [];
98
275
  if (args.noAuth && String(e.auth || '').toLowerCase() !== 'no') return [];
99
276
  if (args.https && !e.https) return [];
277
+ if (args.openapi && !e.openapiUrl) return [];
100
278
  if (args.cors && String(e.cors || '').toLowerCase() !== args.cors.toLowerCase()) return [];
279
+ const matched = q.size ? textScore(e, q) : 1;
280
+ if (q.size && matched === 0) return [];
101
281
  const s = q.size ? score(e, q) : 1;
102
- if (q.size && s === 0) return [];
103
- return [{ ...e, score: s }];
282
+ return [{ ...e, score: s + (e.sourceWeight || 0) }];
104
283
  }).sort((a, b) => b.score - a.score || String(a.category).localeCompare(String(b.category)) || String(a.name).localeCompare(String(b.name))).slice(0, args.limit);
105
284
  }
106
285
 
@@ -110,9 +289,10 @@ function printMarkdown(rows) {
110
289
  return;
111
290
  }
112
291
  rows.forEach((e, i) => {
113
- console.log(`${i + 1}. **${e.name}** (${e.category}) — ${e.description}`);
292
+ console.log(`${i + 1}. **${e.name}** (${e.category}) — ${cleanDescription(e.description)}`);
114
293
  console.log(` - URL: ${e.url}`);
115
- console.log(` - Auth: \`${e.auth}\` · HTTPS: ${e.https ? 'yes' : 'no'} · CORS: ${e.cors} · score: ${e.score}`);
294
+ console.log(` - Auth: \`${e.auth}\` · HTTPS: ${e.https ? 'yes' : 'no'} · CORS: ${e.cors} · sources: ${((e.sources && e.sources.length) ? e.sources : [e.source || 'unknown']).join(', ')} · score: ${e.score}`);
295
+ if (e.openapiUrl) console.log(` - OpenAPI: ${e.openapiUrl}`);
116
296
  });
117
297
  }
118
298