unbrowse 1.1.3 → 1.1.5

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/dist/cli.js CHANGED
@@ -615,24 +615,28 @@ function buildEntityIndex(items) {
615
615
  function detectEntityIndex(data) {
616
616
  if (data == null || typeof data !== "object")
617
617
  return null;
618
- const obj = data;
619
- const candidates = [];
620
- if (Array.isArray(obj.included))
621
- candidates.push(obj.included);
622
- if (obj.data && typeof obj.data === "object") {
623
- const d = obj.data;
624
- if (Array.isArray(d.included))
625
- candidates.push(d.included);
626
- }
627
- for (const arr of candidates) {
618
+ let best = null;
619
+ const check = (arr) => {
628
620
  if (arr.length < 2)
629
- continue;
630
- const sample = arr.slice(0, 5);
621
+ return;
622
+ const sample = arr.slice(0, 10);
631
623
  const withUrn = sample.filter((i) => i != null && typeof i === "object" && typeof i.entityUrn === "string").length;
632
- if (withUrn >= sample.length * 0.5)
633
- return buildEntityIndex(arr);
624
+ if (withUrn >= sample.length * 0.5 && (!best || arr.length > best.length)) {
625
+ best = arr;
626
+ }
627
+ };
628
+ const obj = data;
629
+ for (const val of Object.values(obj)) {
630
+ if (Array.isArray(val)) {
631
+ check(val);
632
+ } else if (val != null && typeof val === "object" && !Array.isArray(val)) {
633
+ for (const nested of Object.values(val)) {
634
+ if (Array.isArray(nested))
635
+ check(nested);
636
+ }
637
+ }
634
638
  }
635
- return null;
639
+ return best ? buildEntityIndex(best) : null;
636
640
  }
637
641
  function resolvePath(obj, path5, entityIndex) {
638
642
  if (!path5 || obj == null)
@@ -658,7 +662,7 @@ function resolvePath(obj, path5, entityIndex) {
658
662
  }
659
663
  const rec = cur;
660
664
  let val = rec[seg];
661
- if (val === undefined && entityIndex) {
665
+ if (val == null && entityIndex) {
662
666
  const ref = rec[`*${seg}`];
663
667
  if (typeof ref === "string") {
664
668
  val = entityIndex.get(ref);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "unbrowse",
3
- "version": "1.1.3",
3
+ "version": "1.1.5",
4
4
  "description": "Reverse-engineer any website into reusable API skills. npm CLI + local engine.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -14,6 +14,7 @@
14
14
  ],
15
15
  "scripts": {
16
16
  "prepack": "bun ../../scripts/sync-skill-md.ts --check && node scripts/prepare-pack.mjs",
17
+ "prepublishOnly": "node scripts/assert-release-flow.mjs",
17
18
  "start": "bun src/index.ts",
18
19
  "dev": "bun --watch src/index.ts"
19
20
  },
@@ -18,6 +18,125 @@ const BETA_API_URL = process.env.UNBROWSE_BACKEND_URL || "https://beta-api.unbro
18
18
 
19
19
  const TRACES_DIR = process.env.TRACES_DIR ?? join(process.cwd(), "traces");
20
20
 
21
+ // ── /v1/stats cache ──────────────────────────────────────────────────
22
+ let statsCache: { data: unknown; ts: number } | null = null;
23
+ const STATS_CACHE_TTL = 5 * 60 * 1000; // 5 minutes
24
+
25
+ async function fetchStats() {
26
+ if (statsCache && Date.now() - statsCache.ts < STATS_CACHE_TTL) {
27
+ return statsCache.data;
28
+ }
29
+
30
+ const npmPoint = (pkg: string, range: string) =>
31
+ fetch(`https://api.npmjs.org/downloads/point/${range}/${pkg}`)
32
+ .then(r => r.json() as Promise<{ downloads?: number }>);
33
+
34
+ const npmRange = (pkg: string) =>
35
+ .then(r => r.json() as Promise<{ downloads?: Array<{ day: string; downloads: number }> }>);
36
+ .then(r => r.json() as Promise<{ downloads?: number; downloads?: Array<{ day: string; downloads: number }> }>);
37
+
38
+ const externalCalls: Promise<unknown>[] = [
39
+ npmPoint("unbrowse", "last-month"),
40
+ npmPoint("@getfoundry/unbrowse-openclaw", "last-month"),
41
+ npmPoint("unbrowse", "1970-01-01:2099-12-31"),
42
+ npmPoint("@getfoundry/unbrowse-openclaw", "1970-01-01:2099-12-31"),
43
+ npmRange("unbrowse"),
44
+ npmRange("@getfoundry/unbrowse-openclaw"),
45
+ fetch("https://api.github.com/repos/anthropic-ai/unbrowse", {
46
+ headers: { "User-Agent": "unbrowse-stats" },
47
+ }).then(r => r.json() as Promise<Record<string, unknown>>),
48
+ ];
49
+
50
+ // Only call Unkey analytics if the key is available as an env var
51
+ const unkeyAnalyticsKey = process.env.UNKEY_ANALYTICS_KEY;
52
+ if (unkeyAnalyticsKey) {
53
+ externalCalls.push(
54
+ fetch("https://api.unkey.com/v2/analytics.getVerifications", {
55
+ method: "POST",
56
+ headers: {
57
+ Authorization: `Bearer ${unkeyAnalyticsKey}`,
58
+ "Content-Type": "application/json",
59
+ },
60
+ body: JSON.stringify({ apiId: "api_2bUScBc8U6JNsXLrhfHwfqzXHJDi" }),
61
+ }).then(r => r.json() as Promise<unknown>),
62
+ );
63
+ }
64
+
65
+ const [
66
+ unbrowse30d, plugin30d,
67
+ unbrowseAll, pluginAll,
68
+ unbrowseDaily, pluginDaily,
69
+ github,
70
+ ...rest
71
+ ] = await Promise.allSettled(externalCalls);
72
+ const unkey = rest[0]; // may be undefined if no key
73
+
74
+ const val = <T>(r: PromiseSettledResult<T> | undefined): T | null =>
75
+ r?.status === "fulfilled" ? r.value : null;
76
+
77
+ // npm numbers
78
+ const u30 = val(unbrowse30d)?.downloads ?? null;
79
+ const p30 = val(plugin30d)?.downloads ?? null;
80
+ const uAll = val(unbrowseAll)?.downloads ?? null;
81
+ const pAll = val(pluginAll)?.downloads ?? null;
82
+
83
+ // daily breakdown — merge the two packages by day
84
+ const uDays = val(unbrowseDaily)?.downloads ?? [];
85
+ const pDays = val(pluginDaily)?.downloads ?? [];
86
+ const dayMap = new Map<string, { unbrowse: number; plugin: number }>();
87
+ for (const d of uDays) dayMap.set(d.day, { unbrowse: d.downloads, plugin: 0 });
88
+ for (const d of pDays) {
89
+ const entry = dayMap.get(d.day);
90
+ if (entry) entry.plugin = d.downloads;
91
+ else dayMap.set(d.day, { unbrowse: 0, plugin: d.downloads });
92
+ }
93
+ const daily = [...dayMap.entries()]
94
+ .sort(([a], [b]) => a.localeCompare(b))
95
+ .map(([day, v]) => ({ day, unbrowse: v.unbrowse, plugin: v.plugin, total: v.unbrowse + v.plugin }));
96
+
97
+ // github
98
+ const gh = val(github);
99
+ const githubData = gh && typeof gh === "object"
100
+ ? {
101
+ stars: (gh as Record<string, number>).stargazers_count ?? null,
102
+ forks: (gh as Record<string, number>).forks_count ?? null,
103
+ open_issues: (gh as Record<string, number>).open_issues_count ?? null,
104
+ watchers: (gh as Record<string, number>).watchers_count ?? null,
105
+ }
106
+ : { stars: null, forks: null, open_issues: null, watchers: null };
107
+
108
+ // unkey
109
+ let agentsData: { total_api_calls_30d: number | null; note?: string } = {
110
+ total_api_calls_30d: null,
111
+ note: "unkey analytics unavailable",
112
+ };
113
+ const uk = val(unkey);
114
+ if (uk && Array.isArray(uk)) {
115
+ const total = (uk as Array<{ total?: number }>).reduce((s, v) => s + (v.total ?? 0), 0);
116
+ agentsData = { total_api_calls_30d: total };
117
+ } else if (uk && typeof uk === "object" && (uk as Record<string, unknown>).total != null) {
118
+ agentsData = { total_api_calls_30d: (uk as Record<string, number>).total };
119
+ }
120
+
121
+ const data = {
122
+ npm: {
123
+ unbrowse: { last_30d: u30, all_time: uAll },
124
+ openclaw_plugin: { last_30d: p30, all_time: pAll },
125
+ combined: {
126
+ last_30d: u30 != null && p30 != null ? u30 + p30 : (u30 ?? p30),
127
+ all_time: uAll != null && pAll != null ? uAll + pAll : (uAll ?? pAll),
128
+ },
129
+ daily,
130
+ },
131
+ github: githubData,
132
+ agents: agentsData,
133
+ fetched_at: new Date().toISOString(),
134
+ };
135
+
136
+ statsCache = { data, ts: Date.now() };
137
+ return data;
138
+ }
139
+
21
140
  export async function registerRoutes(app: FastifyInstance) {
22
141
  const clientScopeFor = (req: { headers: Record<string, unknown>; id: string }) =>
23
142
  (typeof req.headers["x-unbrowse-client-id"] === "string" && req.headers["x-unbrowse-client-id"].trim())
@@ -26,7 +145,7 @@ export async function registerRoutes(app: FastifyInstance) {
26
145
 
27
146
  // Auth gate: block all routes except /health when no API key is configured
28
147
  app.addHook("onRequest", async (req, reply) => {
29
- if (req.url === "/health") return;
148
+ if (req.url === "/health" || req.url === "/v1/stats") return;
30
149
 
31
150
  const key = getApiKey();
32
151
  if (!key) {
@@ -299,6 +418,16 @@ export async function registerRoutes(app: FastifyInstance) {
299
418
  }
300
419
  });
301
420
 
421
+ // GET /v1/stats — public, no auth required
422
+ app.get("/v1/stats", async (_req, reply) => {
423
+ try {
424
+ const data = await fetchStats();
425
+ return reply.send(data);
426
+ } catch (err) {
427
+ return reply.code(500).send({ error: (err as Error).message });
428
+ }
429
+ });
430
+
302
431
  // GET /health
303
432
  app.get("/health", async (_req, reply) => reply.send({ status: "ok", trace_version: TRACE_VERSION, code_hash: CODE_HASH, git_sha: GIT_SHA }));
304
433
 
@@ -118,28 +118,38 @@ function buildEntityIndex(items: unknown[]): Map<string, unknown> {
118
118
  return index;
119
119
  }
120
120
 
121
- /** Detect if an object contains a normalized entity array and build the index. */
121
+ /** Detect if an object contains a normalized entity array and build the index.
122
+ * Searches all top-level and one-level-nested arrays for entityUrn-keyed items,
123
+ * picking the largest qualifying array. Works for any normalized API shape. */
122
124
  function detectEntityIndex(data: unknown): Map<string, unknown> | null {
123
125
  if (data == null || typeof data !== "object") return null;
124
- const obj = data as Record<string, unknown>;
125
126
 
126
- // Check common locations: { included: [...] }, { data: { included: [...] } }
127
- const candidates: unknown[][] = [];
128
- if (Array.isArray(obj.included)) candidates.push(obj.included);
129
- if (obj.data && typeof obj.data === "object") {
130
- const d = obj.data as Record<string, unknown>;
131
- if (Array.isArray(d.included)) candidates.push(d.included);
132
- }
127
+ let best: unknown[] | null = null;
133
128
 
134
- for (const arr of candidates) {
135
- if (arr.length < 2) continue;
136
- const sample = arr.slice(0, 5);
129
+ const check = (arr: unknown[]) => {
130
+ if (arr.length < 2) return;
131
+ const sample = arr.slice(0, 10);
137
132
  const withUrn = sample.filter(
138
133
  (i) => i != null && typeof i === "object" && typeof (i as Record<string, unknown>).entityUrn === "string"
139
134
  ).length;
140
- if (withUrn >= sample.length * 0.5) return buildEntityIndex(arr);
135
+ if (withUrn >= sample.length * 0.5 && (!best || arr.length > best.length)) {
136
+ best = arr;
137
+ }
138
+ };
139
+
140
+ const obj = data as Record<string, unknown>;
141
+ for (const val of Object.values(obj)) {
142
+ if (Array.isArray(val)) {
143
+ check(val);
144
+ } else if (val != null && typeof val === "object" && !Array.isArray(val)) {
145
+ // One level deep: { data: { included: [...] } }, { response: { entities: [...] } }, etc.
146
+ for (const nested of Object.values(val as Record<string, unknown>)) {
147
+ if (Array.isArray(nested)) check(nested);
148
+ }
149
+ }
141
150
  }
142
- return null;
151
+
152
+ return best ? buildEntityIndex(best) : null;
143
153
  }
144
154
 
145
155
  /** Resolve a dot-path like "data.items[].name" against an object.
@@ -165,8 +175,10 @@ function resolvePath(obj: unknown, path: string, entityIndex?: Map<string, unkno
165
175
  const rec = cur as Record<string, unknown>;
166
176
  let val = rec[seg];
167
177
 
168
- // URN reference resolution: if direct lookup fails, check for "*key" reference
169
- if (val === undefined && entityIndex) {
178
+ // URN reference resolution: if direct lookup fails (or is null), check for "*key" reference.
179
+ // Normalized APIs (LinkedIn Voyager, Facebook Graph) set inline fields to null when
180
+ // the value is stored as a URN reference: e.g. socialDetail: null + *socialDetail: "urn:li:..."
181
+ if (val == null && entityIndex) {
170
182
  const ref = rec[`*${seg}`];
171
183
  if (typeof ref === "string") {
172
184
  val = entityIndex.get(ref);