@builtbyecho/public-api-finder 0.5.7 → 0.5.8

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@builtbyecho/public-api-finder",
3
- "version": "0.5.7",
3
+ "version": "0.5.8",
4
4
  "description": "Find free/public APIs for agents and prototypes.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -45,7 +45,7 @@ const cases = [
45
45
  { q: 'food barcode ingredients allergens nutrition no auth cors', args: ['--no-auth','--https'], topCategory: /food/i, mustName: /open food facts/i },
46
46
  { q: 'recipe from pantry ingredients avoid allergens api', args: [], topCategory: /food/i, mustName: /spoonacular|edamam|recipe|meal/i },
47
47
  { q: 'air quality by coordinates pollutant measurements', args: [], topCategory: /environment|science|weather/i, mustName: /openaq|air quality|aqicn/i },
48
- { q: 'historical currency exchange rates no auth cors', args: ['--no-auth','--https'], topCategory: /currency exchange/i, mustName: /frankfurter|currency/i, forbid: /crypto|bitcoin|dex/i },
48
+ { q: 'historical currency exchange rates no auth cors', args: ['--no-auth','--https'], topCategory: /currency exchange/i, mustName: /frankfurter|currency/i, forbid: /\b(crypto|bitcoin|dex)\b/i },
49
49
  { q: 'public holidays by country no auth', args: ['--no-auth','--https'], topCategory: /calendar|date/i, mustName: /nager|holiday|calendarific/i },
50
50
  { q: 'vehicle vin decode recall safety data no auth', args: ['--no-auth'], topCategory: /transportation|government|open data/i, mustName: /nhtsa|vin|vehicle/i },
51
51
  { q: 'real estate property value rent estimate api', args: [], topCategory: /real estate|property|openapi|finance/i, mustName: /rentcast|attom|zillow|real estate|property/i },
@@ -0,0 +1,56 @@
1
+ #!/usr/bin/env node
2
+ import { spawnSync } from 'node:child_process';
3
+
4
+ const cases = [
5
+ { q: 'free stock data for a frontend chart', args: ['--no-auth','--https'], expect: /finance/i, any: /stooq|portfolio|stock|finance/i, forbid: /weather|joke|anime/i },
6
+ { q: 'api for checking token liquidity on dexes', args: [], expect: /cryptocurrency/i, any: /dexscreener|dexpaprika|geckoterminal|defillama/i },
7
+ { q: 'browser safe weather forecast api', args: ['--no-auth','--https','--cors','Yes'], expect: /weather/i, any: /open-meteo|weather|pirate/i },
8
+ { q: 'whois and dns lookup for a domain', args: [], expect: /security|development|openapi/i, any: /whois|dns|ssl/i },
9
+ { q: 'send text messages to users api', args: [], expect: /communication|messaging|telecom|openapi/i, any: /twilio|sms|message|telnyx|vonage/i },
10
+ { q: 'make pdf from html api', args: [], expect: /documents|development|openapi/i, any: /pdf|html/i },
11
+ { q: 'find APIs for app screenshots', args: [], expect: /development|media|documents|openapi/i, any: /urlbox|microlink|screenshot|capture/i },
12
+ { q: 'check if npm package has vulnerabilities', args: [], expect: /security|development|open data/i, any: /osv|nvd|vulnerability|npm/i },
13
+ { q: 'lookup us census demographics by zip', args: [], expect: /government|open data|geocoding/i, any: /census|zippopotam|data.gov/i },
14
+ { q: 'temporary email inbox for tests', args: ['--no-auth','--https'], expect: /email/i, any: /mail|email|inbox/i },
15
+ { q: 'login users with magic link api', args: [], expect: /authentication|security|openapi/i, any: /stytch|magic|auth0|clerk|passwordless/i },
16
+ { q: 'mock webhooks during development', args: [], expect: /development|test data|openapi/i, any: /webhook|beeceptor|requestbin|mock/i },
17
+ { q: 'public holidays calendar for germany', args: ['--no-auth','--https'], expect: /calendar|date/i, any: /nager|holiday|calendar/i },
18
+ { q: 'random user data for frontend seed demo', args: ['--no-auth','--https'], expect: /test data/i, any: /random user|jsonplaceholder|dummyjson|fake/i },
19
+ { q: 'recipe nutrition search by ingredients', args: [], expect: /food/i, any: /spoonacular|edamam|open food|recipe|nutrition/i },
20
+ { q: 'flight arrivals by airport code', args: [], expect: /travel|transportation|openapi/i, any: /aviation|flight|airport|amadeus/i },
21
+ { q: 'rent estimate property valuation api', args: [], expect: /real estate|property|finance|openapi/i, any: /rentcast|attom|property|real estate/i },
22
+ { q: 'container image tags registry api', args: [], expect: /development|security|openapi/i, any: /docker|registry|container/i },
23
+ { q: 'open graph card preview for url', args: [], expect: /development|media|utility|openapi/i, any: /microlink|open graph|link preview|metadata/i },
24
+ { q: 'speech transcription from uploaded audio', args: [], expect: /ai|audio|machine learning|openapi/i, any: /assemblyai|deepgram|whisper|transcription/i },
25
+ // Over-filter / tight filter checks: should not pad unrelated junk.
26
+ { q: 'stock quote no auth cors yes', args: ['--no-auth','--https','--cors','Yes'], any: /portfolio|stooq|stock|finance/i, forbid: /weather|joke|tvmaze|anime|jobs/i, max: 5, allowEmpty: true },
27
+ { q: 'crypto exchange orderbook no auth cors yes', args: ['--no-auth','--https','--cors','Yes'], expect: /cryptocurrency/i, any: /0x|coinpaprika|dexpaprika|dex|crypto/i, forbid: /weather|stock|joke/i, max: 5 },
28
+ { q: 'oauth login no auth cors yes', args: ['--no-auth','--https','--cors','Yes'], forbid: /weather|joke|food|crypto|stock|calendar|holiday/i, max: 5, allowEmpty: true },
29
+ { q: 'webhook testing no auth cors yes', args: ['--no-auth','--https','--cors','Yes'], any: /webhook|beeceptor|jsonplaceholder|mock/i, forbid: /weather|crypto|stock/i, max: 5 },
30
+ { q: 'medical diagnosis api no auth cors yes', args: ['--no-auth','--https','--cors','Yes'], forbid: /weather|crypto|stock|joke|tvmaze|anime|email|animals|food/i, max: 5, allowEmpty: true },
31
+ ];
32
+
33
+ function checkRows(rows, c) {
34
+ const checks = [];
35
+ const top = rows[0];
36
+ if (!top && !c.allowEmpty) checks.push('no results');
37
+ if (top && c.expect && !c.expect.test(top.category || '')) checks.push(`top category ${top.category} !~ ${c.expect}`);
38
+ if (c.any && !(c.allowEmpty && rows.length === 0) && !rows.slice(0, 3).some(r => c.any.test(`${r.name} ${r.category} ${r.description} ${r.url}`))) checks.push(`top3 missing ${c.any}`);
39
+ if (c.forbid && rows.some(r => c.forbid.test(`${r.name} ${r.category} ${r.description}`))) checks.push(`forbidden ${c.forbid}`);
40
+ if (c.max && rows.length > c.max) checks.push(`too many rows ${rows.length} > ${c.max}`);
41
+ return checks;
42
+ }
43
+
44
+ let failures = 0;
45
+ for (const [i, c] of cases.entries()) {
46
+ const res = spawnSync(process.execPath, ['src/cli.js', c.q, ...c.args, '--limit', String(c.max || 5), '--json'], { encoding: 'utf8' });
47
+ const rows = res.status === 0 ? JSON.parse(res.stdout || '[]') : [];
48
+ const checks = checkRows(rows, c);
49
+ const ok = checks.length === 0;
50
+ if (!ok) failures++;
51
+ console.log(`${ok ? 'PASS' : 'FAIL'} ${i + 1}. ${c.q}`);
52
+ rows.slice(0, 3).forEach((r, idx) => console.log(` ${idx + 1}. ${r.name} | ${r.category} | auth=${r.auth} | cors=${r.cors} | score=${r.score}`));
53
+ if (!ok) console.log(` Reasons: ${checks.join('; ')}`);
54
+ }
55
+ console.log(`\n${cases.length - failures}/${cases.length} mutation cases passed`);
56
+ process.exitCode = failures ? 1 : 0;
package/src/cli.js CHANGED
@@ -13,7 +13,7 @@ const SOURCES = {
13
13
  };
14
14
  const CACHE_PATH = process.env.PUBLIC_API_FINDER_CACHE || join(homedir(), '.cache', 'public-api-finder', 'all.json');
15
15
  const CACHE_TTL_MS = 24 * 60 * 60 * 1000;
16
- const DATA_VERSION = 16;
16
+ const DATA_VERSION = 17;
17
17
 
18
18
  const ENRICHMENT_FIELDS = [
19
19
  'tags',
@@ -747,7 +747,17 @@ function applyQueryHints(args) {
747
747
  if (!args.cors && /\b(cors|frontend-safe|browser-safe|frontend safe|browser safe)\b/.test(query)) args.cors = 'Yes';
748
748
  }
749
749
 
750
- const SEARCH_STOPWORDS = new Set(['a', 'an', 'and', 'api', 'apis', 'for', 'from', 'in', 'no', 'of', 'on', 'or', 'the', 'to', 'with']);
750
+ const SEARCH_STOPWORDS = new Set(['a', 'an', 'and', 'api', 'apis', 'auth', 'cors', 'for', 'from', 'in', 'key', 'no', 'of', 'on', 'or', 'the', 'to', 'with', 'yes']);
751
+
752
+
753
+ function normalizeSearchQuery(query) {
754
+ return String(query || '')
755
+ .replace(/\b(no auth|without auth|no api key|no key|unauthenticated)\b/gi, ' ')
756
+ .replace(/\b(cors|cors yes|frontend-safe|browser-safe|frontend safe|browser safe|https)\b/gi, ' ')
757
+ .replace(/\b(api|apis)\b/gi, ' ')
758
+ .replace(/\s+/g, ' ')
759
+ .trim();
760
+ }
751
761
 
752
762
  function tokenSet(text) {
753
763
  return new Set(String(text).toLowerCase().match(/[a-z0-9]+/g)?.filter(t => t.length > 1 && !SEARCH_STOPWORDS.has(t)) || []);
@@ -852,10 +862,11 @@ function intentPenalty(entry, queryText) {
852
862
  if (/\b(school district|school boundary|district boundary|reverse geocoding|maps routing|routing api|open data demographics|demographics|census data)\b/.test(queryText) && /\b(linkedin|jobs scraper|lead|sales|recruiting|amazon product scraper|tiktok profile scraper)\b/.test(text)) {
853
863
  penalty += 120;
854
864
  }
855
- if (/\b(stock|stocks|stock quote|stock prices|equity|equities|ticker|tickers)\b/.test(queryText) && !/finance|financial|stock|stocks|equity|ticker|market data|portfolio|stooq|finnhub|polygon|alpha vantage|twelve data/.test(text)) {
865
+ const stockIntent = /\b(stock|stocks|stock quote|stock prices|equity|equities|ticker|tickers)\b/.test(queryText) && !/\b(not stocks?|not stock|non stocks?|non stock)\b/.test(queryText);
866
+ if (stockIntent && !/finance|financial|stock|stocks|equity|ticker|market data|portfolio|stooq|finnhub|polygon|alpha vantage|twelve data/.test(text)) {
856
867
  penalty += 80;
857
868
  }
858
- if (/\b(stock|stocks|stock quote|stock prices|equity|equities|ticker|tickers)\b/.test(queryText) && /\b(quotable|joke|entertainment|food|weather|test data|placeholder)\b/.test(text)) {
869
+ if (stockIntent && /\b(quotable|joke|entertainment|food|weather|test data|placeholder)\b/.test(text)) {
859
870
  penalty += 140;
860
871
  }
861
872
  if (/\bcrypto\b/.test(queryText) && /\bnot stocks?\b/.test(queryText) && /\b(finance|financial|stock|stocks|equity|ticker|portfolio|stooq|finnhub|polygon|sec edgar|predscope|valueray|econdb)\b/.test(text)) {
@@ -880,6 +891,16 @@ function intentPenalty(entry, queryText) {
880
891
  penalty += 70;
881
892
  }
882
893
 
894
+ if (/\b(crypto|cryptocurrency|dex|orderbook)\b/.test(queryText) && /\b(currency exchange|frankfurter|national bank|vatcomply|exchange rates only|fiat)\b/.test(text) && !/\b(crypto|cryptocurrency|dex|token|blockchain|defi|coinpaprika|0x)\b/.test(text)) {
895
+ penalty += 160;
896
+ }
897
+ if (/\b(oauth|login|auth|authentication|passwordless)\b/.test(queryText) && /\b(calendar|holiday|nameday|nager|non-working days|food|weather|crypto|stock)\b/.test(text) && !/\b(auth|oauth|openid|login|identity|passwordless)\b/.test(text)) {
898
+ penalty += 140;
899
+ }
900
+ if (/\b(medical diagnosis|diagnosis|clinical|symptom checker)\b/.test(queryText) && /\b(food|crypto|currency|weather|joke|tv|geocoding|zippopotam)\b/.test(text) && !/\b(medical|health|clinical|diagnosis|symptom|fhir)\b/.test(text)) {
901
+ penalty += 180;
902
+ }
903
+
883
904
  if (!cat.includes('cryptocurrency') && /\b(wallet address|identicon|avatar|profile picture)\b/.test(queryText) && /\b(avatar|identicon|profile picture)\b/.test(text)) {
884
905
  penalty -= 20;
885
906
  }
@@ -1137,8 +1158,38 @@ function sourceMatches(entry, source) {
1137
1158
  return (entry.sources || [entry.source]).some(s => String(s).toLowerCase() === source.toLowerCase());
1138
1159
  }
1139
1160
 
1161
+
1162
+ function passesIntentGate(entry, queryText) {
1163
+ const text = `${entry.name || ''} ${entry.category || ''} ${entry.description || ''} ${enrichedText(entry)}`.toLowerCase();
1164
+ if (/\b(currency exchange|exchange rates|fiat exchange|forex rates)\b/.test(queryText) && !/\bcrypto|bitcoin|dex|token\b/.test(queryText)) {
1165
+ if (/\bcurrency exchange\b/.test(String(entry.category || '').toLowerCase())) return true;
1166
+ if (/\b(frankfurter|national bank|vatcomply)\b/.test(String(entry.name || '').toLowerCase())) return true;
1167
+ if (/\b(crypto|crypto-currencies|cryptocurrency|stocks?|portfolio optimizer|econdb)\b/.test(text)) return false;
1168
+ return /\b(currency conversion|forex rates)\b/.test(text);
1169
+ }
1170
+ if (/\b(stock|stocks|stock quote|stock prices|equity|equities|ticker|tickers)\b/.test(queryText) && !/\b(not stocks?|not stock|non stocks?|non stock)\b/.test(queryText)) {
1171
+ return /\b(finance|financial|currency exchange)\b/.test(String(entry.category || '').toLowerCase())
1172
+ || /\b(stooq|portfolio optimizer|alpha vantage|finnhub|polygon|twelve data|sec edgar|econdb|tradier|predscope|valueray)\b/.test(text);
1173
+ }
1174
+ if (/\b(oauth|openid|passwordless|magic link)\b/.test(queryText) || /\blogin\b.*\b(auth|users?)\b/.test(queryText)) {
1175
+ return /\b(authentication|security)\b/.test(String(entry.category || '').toLowerCase())
1176
+ || /\b(auth0|clerk|stytch|magic|oauth|openid|identity|passwordless|social auth|login)\b/.test(text);
1177
+ }
1178
+ if (/\b(medical diagnosis|diagnosis|clinical|symptom checker)\b/.test(queryText)) {
1179
+ return /\b(medical|health|clinical|diagnosis|symptom|fhir)\b/.test(text);
1180
+ }
1181
+ if (/\b(crypto|cryptocurrency|dex|orderbook)\b/.test(queryText) && !/\b(not crypto|non crypto|fiat only)\b/.test(queryText)) {
1182
+ if (!/\bcryptocurrency\b/.test(String(entry.category || '').toLowerCase()) && /\b(finance|financial|currency exchange)\b/.test(String(entry.category || '').toLowerCase())) return false;
1183
+ return /\b(crypto|cryptocurrency|dex|defi|token|blockchain|coin|coinpaprika|0x|dexpaprika|geckoterminal|dexscreener)\b/.test(text);
1184
+ }
1185
+ return true;
1186
+ }
1187
+
1140
1188
  function filterEntries(entries, args) {
1141
- const q = tokenSet(args.query);
1189
+ const queryText = String(args.query || '').toLowerCase();
1190
+ const searchText = normalizeSearchQuery(args.query);
1191
+ const q = tokenSet(searchText);
1192
+ if (args.noAuth && (/\b(oauth|openid|passwordless|magic link)\b/.test(queryText) || /\blogin\b.*\b(auth|users?)\b/.test(queryText))) return [];
1142
1193
  return entries.flatMap(e => {
1143
1194
  if (args.category && !String(e.category || '').toLowerCase().includes(args.category.toLowerCase())) return [];
1144
1195
  if (args.source && !sourceMatches(e, args.source)) return [];
@@ -1146,13 +1197,15 @@ function filterEntries(entries, args) {
1146
1197
  if (args.https && !e.https) return [];
1147
1198
  if (args.openapi && !e.openapiUrl) return [];
1148
1199
  if (args.cors && String(e.cors || '').toLowerCase() !== args.cors.toLowerCase()) return [];
1200
+ if (q.size && !passesIntentGate(e, queryText)) return [];
1149
1201
  const matched = q.size ? textScore(e, q) : 1;
1150
1202
  const domain = q.size ? domainAdjustment(e, q) : 0;
1151
- const targeted = q.size ? targetedBoost(e, args.query.toLowerCase()) : 0;
1203
+ const targeted = q.size ? targetedBoost(e, queryText) : 0;
1152
1204
  if (q.size && matched === 0 && domain <= 0 && targeted <= 0) return [];
1153
- const s = q.size ? score(e, q, args.query.toLowerCase()) : 1;
1205
+ let s = q.size ? score(e, q, searchText.toLowerCase()) : 1;
1154
1206
  if (q.size && s <= 0) return [];
1155
- return [{ ...e, score: s + (e.sourceWeight || 0) }];
1207
+ const finalScore = s + (e.sourceWeight || 0);
1208
+ return [{ ...e, score: finalScore }];
1156
1209
  }).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);
1157
1210
  }
1158
1211