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 +20 -16
- package/package.json +2 -1
- package/runtime-src/api/routes.ts +130 -1
- package/runtime-src/cli.ts +28 -16
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
|
-
|
|
619
|
-
const
|
|
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
|
-
|
|
630
|
-
const sample = arr.slice(0,
|
|
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
|
-
|
|
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
|
|
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
|
+
"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
|
|
package/runtime-src/cli.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
135
|
-
if (arr.length < 2)
|
|
136
|
-
const sample = arr.slice(0,
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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);
|