@oh-my-pi/pi-coding-agent 3.25.0 → 3.30.0
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/CHANGELOG.md +19 -0
- package/package.json +4 -4
- package/src/core/tools/complete.ts +2 -4
- package/src/core/tools/jtd-to-json-schema.ts +174 -196
- package/src/core/tools/read.ts +4 -4
- package/src/core/tools/task/executor.ts +146 -20
- package/src/core/tools/task/name-generator.ts +1544 -214
- package/src/core/tools/task/types.ts +19 -5
- package/src/core/tools/task/worker.ts +103 -13
- package/src/core/tools/web-fetch-handlers/academic.test.ts +239 -0
- package/src/core/tools/web-fetch-handlers/artifacthub.ts +210 -0
- package/src/core/tools/web-fetch-handlers/arxiv.ts +84 -0
- package/src/core/tools/web-fetch-handlers/aur.ts +171 -0
- package/src/core/tools/web-fetch-handlers/biorxiv.ts +136 -0
- package/src/core/tools/web-fetch-handlers/bluesky.ts +277 -0
- package/src/core/tools/web-fetch-handlers/brew.ts +173 -0
- package/src/core/tools/web-fetch-handlers/business.test.ts +82 -0
- package/src/core/tools/web-fetch-handlers/cheatsh.ts +73 -0
- package/src/core/tools/web-fetch-handlers/chocolatey.ts +153 -0
- package/src/core/tools/web-fetch-handlers/coingecko.ts +179 -0
- package/src/core/tools/web-fetch-handlers/crates-io.ts +123 -0
- package/src/core/tools/web-fetch-handlers/dev-platforms.test.ts +254 -0
- package/src/core/tools/web-fetch-handlers/devto.ts +173 -0
- package/src/core/tools/web-fetch-handlers/discogs.ts +303 -0
- package/src/core/tools/web-fetch-handlers/dockerhub.ts +156 -0
- package/src/core/tools/web-fetch-handlers/documentation.test.ts +85 -0
- package/src/core/tools/web-fetch-handlers/finance-media.test.ts +144 -0
- package/src/core/tools/web-fetch-handlers/git-hosting.test.ts +272 -0
- package/src/core/tools/web-fetch-handlers/github-gist.ts +64 -0
- package/src/core/tools/web-fetch-handlers/github.ts +424 -0
- package/src/core/tools/web-fetch-handlers/gitlab.ts +444 -0
- package/src/core/tools/web-fetch-handlers/go-pkg.ts +271 -0
- package/src/core/tools/web-fetch-handlers/hackage.ts +89 -0
- package/src/core/tools/web-fetch-handlers/hackernews.ts +208 -0
- package/src/core/tools/web-fetch-handlers/hex.ts +121 -0
- package/src/core/tools/web-fetch-handlers/huggingface.ts +385 -0
- package/src/core/tools/web-fetch-handlers/iacr.ts +82 -0
- package/src/core/tools/web-fetch-handlers/index.ts +69 -0
- package/src/core/tools/web-fetch-handlers/lobsters.ts +186 -0
- package/src/core/tools/web-fetch-handlers/mastodon.ts +302 -0
- package/src/core/tools/web-fetch-handlers/maven.ts +147 -0
- package/src/core/tools/web-fetch-handlers/mdn.ts +174 -0
- package/src/core/tools/web-fetch-handlers/media.test.ts +138 -0
- package/src/core/tools/web-fetch-handlers/metacpan.ts +247 -0
- package/src/core/tools/web-fetch-handlers/npm.ts +107 -0
- package/src/core/tools/web-fetch-handlers/nuget.ts +201 -0
- package/src/core/tools/web-fetch-handlers/nvd.ts +238 -0
- package/src/core/tools/web-fetch-handlers/opencorporates.ts +273 -0
- package/src/core/tools/web-fetch-handlers/openlibrary.ts +313 -0
- package/src/core/tools/web-fetch-handlers/osv.ts +184 -0
- package/src/core/tools/web-fetch-handlers/package-managers-2.test.ts +199 -0
- package/src/core/tools/web-fetch-handlers/package-managers.test.ts +171 -0
- package/src/core/tools/web-fetch-handlers/package-registries.test.ts +259 -0
- package/src/core/tools/web-fetch-handlers/packagist.ts +170 -0
- package/src/core/tools/web-fetch-handlers/pub-dev.ts +185 -0
- package/src/core/tools/web-fetch-handlers/pubmed.ts +174 -0
- package/src/core/tools/web-fetch-handlers/pypi.ts +125 -0
- package/src/core/tools/web-fetch-handlers/readthedocs.ts +122 -0
- package/src/core/tools/web-fetch-handlers/reddit.ts +100 -0
- package/src/core/tools/web-fetch-handlers/repology.ts +257 -0
- package/src/core/tools/web-fetch-handlers/research.test.ts +107 -0
- package/src/core/tools/web-fetch-handlers/rfc.ts +205 -0
- package/src/core/tools/web-fetch-handlers/rubygems.ts +112 -0
- package/src/core/tools/web-fetch-handlers/sec-edgar.ts +269 -0
- package/src/core/tools/web-fetch-handlers/security.test.ts +103 -0
- package/src/core/tools/web-fetch-handlers/semantic-scholar.ts +190 -0
- package/src/core/tools/web-fetch-handlers/social-extended.test.ts +192 -0
- package/src/core/tools/web-fetch-handlers/social.test.ts +259 -0
- package/src/core/tools/web-fetch-handlers/spotify.ts +218 -0
- package/src/core/tools/web-fetch-handlers/stackexchange.test.ts +120 -0
- package/src/core/tools/web-fetch-handlers/stackoverflow.ts +123 -0
- package/src/core/tools/web-fetch-handlers/standards.test.ts +122 -0
- package/src/core/tools/web-fetch-handlers/terraform.ts +296 -0
- package/src/core/tools/web-fetch-handlers/tldr.ts +47 -0
- package/src/core/tools/web-fetch-handlers/twitter.ts +84 -0
- package/src/core/tools/web-fetch-handlers/types.ts +163 -0
- package/src/core/tools/web-fetch-handlers/utils.ts +91 -0
- package/src/core/tools/web-fetch-handlers/vimeo.ts +152 -0
- package/src/core/tools/web-fetch-handlers/wikidata.ts +349 -0
- package/src/core/tools/web-fetch-handlers/wikipedia.test.ts +73 -0
- package/src/core/tools/web-fetch-handlers/wikipedia.ts +91 -0
- package/src/core/tools/web-fetch-handlers/youtube.test.ts +198 -0
- package/src/core/tools/web-fetch-handlers/youtube.ts +319 -0
- package/src/core/tools/web-fetch.ts +152 -1324
- package/src/utils/tools-manager.ts +110 -8
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Discogs URL handler for music releases and masters
|
|
3
|
+
*
|
|
4
|
+
* Uses the Discogs API to extract structured metadata about releases.
|
|
5
|
+
* API docs: https://www.discogs.com/developers
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { RenderResult, SpecialHandler } from "./types";
|
|
9
|
+
import { finalizeOutput, loadPage } from "./types";
|
|
10
|
+
|
|
11
|
+
interface DiscogsArtist {
|
|
12
|
+
name: string;
|
|
13
|
+
anv?: string; // artist name variation
|
|
14
|
+
role?: string;
|
|
15
|
+
join?: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
interface DiscogsTrack {
|
|
19
|
+
position: string;
|
|
20
|
+
title: string;
|
|
21
|
+
duration?: string;
|
|
22
|
+
artists?: DiscogsArtist[];
|
|
23
|
+
extraartists?: DiscogsArtist[];
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
interface DiscogsLabel {
|
|
27
|
+
name: string;
|
|
28
|
+
catno?: string; // catalog number
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
interface DiscogsFormat {
|
|
32
|
+
name: string;
|
|
33
|
+
qty?: string;
|
|
34
|
+
descriptions?: string[];
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
interface DiscogsRelease {
|
|
38
|
+
id: number;
|
|
39
|
+
title: string;
|
|
40
|
+
artists?: DiscogsArtist[];
|
|
41
|
+
year?: number;
|
|
42
|
+
released?: string;
|
|
43
|
+
country?: string;
|
|
44
|
+
genres?: string[];
|
|
45
|
+
styles?: string[];
|
|
46
|
+
labels?: DiscogsLabel[];
|
|
47
|
+
formats?: DiscogsFormat[];
|
|
48
|
+
tracklist?: DiscogsTrack[];
|
|
49
|
+
extraartists?: DiscogsArtist[];
|
|
50
|
+
notes?: string;
|
|
51
|
+
uri?: string;
|
|
52
|
+
master_id?: number;
|
|
53
|
+
master_url?: string;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
interface DiscogsMaster {
|
|
57
|
+
id: number;
|
|
58
|
+
title: string;
|
|
59
|
+
artists?: DiscogsArtist[];
|
|
60
|
+
year?: number;
|
|
61
|
+
genres?: string[];
|
|
62
|
+
styles?: string[];
|
|
63
|
+
tracklist?: DiscogsTrack[];
|
|
64
|
+
notes?: string;
|
|
65
|
+
uri?: string;
|
|
66
|
+
main_release?: number;
|
|
67
|
+
main_release_url?: string;
|
|
68
|
+
versions_url?: string;
|
|
69
|
+
num_for_sale?: number;
|
|
70
|
+
lowest_price?: number;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Format artist names, handling name variations
|
|
75
|
+
*/
|
|
76
|
+
function formatArtists(artists: DiscogsArtist[] | undefined): string {
|
|
77
|
+
if (!artists?.length) return "Unknown Artist";
|
|
78
|
+
return artists
|
|
79
|
+
.map((a) => {
|
|
80
|
+
const name = a.anv || a.name;
|
|
81
|
+
const join = a.join || ", ";
|
|
82
|
+
return name + (a.join ? ` ${join} ` : "");
|
|
83
|
+
})
|
|
84
|
+
.join("")
|
|
85
|
+
.replace(/[,&]\s*$/, "")
|
|
86
|
+
.trim();
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Format a single track
|
|
91
|
+
*/
|
|
92
|
+
function formatTrack(track: DiscogsTrack): string {
|
|
93
|
+
let line = track.position ? `${track.position}. ` : "- ";
|
|
94
|
+
line += track.title;
|
|
95
|
+
if (track.duration) line += ` (${track.duration})`;
|
|
96
|
+
if (track.artists?.length) {
|
|
97
|
+
line += ` - ${formatArtists(track.artists)}`;
|
|
98
|
+
}
|
|
99
|
+
return line;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Format credits/extraartists grouped by role
|
|
104
|
+
*/
|
|
105
|
+
function formatCredits(extraartists: DiscogsArtist[] | undefined): string {
|
|
106
|
+
if (!extraartists?.length) return "";
|
|
107
|
+
|
|
108
|
+
const byRole: Record<string, string[]> = {};
|
|
109
|
+
for (const artist of extraartists) {
|
|
110
|
+
const role = artist.role || "Other";
|
|
111
|
+
if (!byRole[role]) byRole[role] = [];
|
|
112
|
+
byRole[role].push(artist.anv || artist.name);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const lines: string[] = [];
|
|
116
|
+
for (const [role, names] of Object.entries(byRole)) {
|
|
117
|
+
lines.push(`- **${role}**: ${names.join(", ")}`);
|
|
118
|
+
}
|
|
119
|
+
return lines.join("\n");
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Format formats (e.g., "2×LP, Album, Reissue")
|
|
124
|
+
*/
|
|
125
|
+
function formatFormats(formats: DiscogsFormat[] | undefined): string {
|
|
126
|
+
if (!formats?.length) return "";
|
|
127
|
+
|
|
128
|
+
return formats
|
|
129
|
+
.map((f) => {
|
|
130
|
+
const parts: string[] = [];
|
|
131
|
+
if (f.qty && parseInt(f.qty, 10) > 1) parts.push(`${f.qty}×`);
|
|
132
|
+
parts.push(f.name);
|
|
133
|
+
if (f.descriptions?.length) parts.push(f.descriptions.join(", "));
|
|
134
|
+
return parts.join(" ");
|
|
135
|
+
})
|
|
136
|
+
.join(" + ");
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Format labels with catalog numbers
|
|
141
|
+
*/
|
|
142
|
+
function formatLabels(labels: DiscogsLabel[] | undefined): string {
|
|
143
|
+
if (!labels?.length) return "";
|
|
144
|
+
return labels
|
|
145
|
+
.map((l) => {
|
|
146
|
+
if (l.catno && l.catno !== "none") return `${l.name} (${l.catno})`;
|
|
147
|
+
return l.name;
|
|
148
|
+
})
|
|
149
|
+
.join(", ");
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Build markdown for a release
|
|
154
|
+
*/
|
|
155
|
+
function buildReleaseMarkdown(release: DiscogsRelease): string {
|
|
156
|
+
const sections: string[] = [];
|
|
157
|
+
|
|
158
|
+
// Title with artist
|
|
159
|
+
const artist = formatArtists(release.artists);
|
|
160
|
+
sections.push(`# ${artist} - ${release.title}\n`);
|
|
161
|
+
|
|
162
|
+
// Metadata
|
|
163
|
+
const meta: string[] = [];
|
|
164
|
+
if (release.year) meta.push(`**Year**: ${release.year}`);
|
|
165
|
+
if (release.country) meta.push(`**Country**: ${release.country}`);
|
|
166
|
+
|
|
167
|
+
const format = formatFormats(release.formats);
|
|
168
|
+
if (format) meta.push(`**Format**: ${format}`);
|
|
169
|
+
|
|
170
|
+
const labels = formatLabels(release.labels);
|
|
171
|
+
if (labels) meta.push(`**Label**: ${labels}`);
|
|
172
|
+
|
|
173
|
+
if (release.genres?.length) meta.push(`**Genre**: ${release.genres.join(", ")}`);
|
|
174
|
+
if (release.styles?.length) meta.push(`**Style**: ${release.styles.join(", ")}`);
|
|
175
|
+
|
|
176
|
+
if (release.master_id) {
|
|
177
|
+
meta.push(`**Master Release**: [${release.master_id}](https://www.discogs.com/master/${release.master_id})`);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
if (meta.length) sections.push(`${meta.join("\n")}\n`);
|
|
181
|
+
|
|
182
|
+
// Tracklist
|
|
183
|
+
if (release.tracklist?.length) {
|
|
184
|
+
sections.push("## Tracklist\n");
|
|
185
|
+
const tracks = release.tracklist.map(formatTrack);
|
|
186
|
+
sections.push(`${tracks.join("\n")}\n`);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// Credits
|
|
190
|
+
const credits = formatCredits(release.extraartists);
|
|
191
|
+
if (credits) {
|
|
192
|
+
sections.push("## Credits\n");
|
|
193
|
+
sections.push(`${credits}\n`);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// Notes
|
|
197
|
+
if (release.notes) {
|
|
198
|
+
sections.push("## Notes\n");
|
|
199
|
+
sections.push(`${release.notes}\n`);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
return sections.join("\n");
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Build markdown for a master release
|
|
207
|
+
*/
|
|
208
|
+
function buildMasterMarkdown(master: DiscogsMaster): string {
|
|
209
|
+
const sections: string[] = [];
|
|
210
|
+
|
|
211
|
+
// Title with artist
|
|
212
|
+
const artist = formatArtists(master.artists);
|
|
213
|
+
sections.push(`# ${artist} - ${master.title}\n`);
|
|
214
|
+
sections.push("*Master Release*\n");
|
|
215
|
+
|
|
216
|
+
// Metadata
|
|
217
|
+
const meta: string[] = [];
|
|
218
|
+
if (master.year) meta.push(`**Year**: ${master.year}`);
|
|
219
|
+
if (master.genres?.length) meta.push(`**Genre**: ${master.genres.join(", ")}`);
|
|
220
|
+
if (master.styles?.length) meta.push(`**Style**: ${master.styles.join(", ")}`);
|
|
221
|
+
|
|
222
|
+
if (master.main_release) {
|
|
223
|
+
meta.push(`**Main Release**: [${master.main_release}](https://www.discogs.com/release/${master.main_release})`);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
if (master.num_for_sale !== undefined && master.num_for_sale > 0) {
|
|
227
|
+
meta.push(`**For Sale**: ${master.num_for_sale} copies`);
|
|
228
|
+
if (master.lowest_price !== undefined) {
|
|
229
|
+
meta.push(`**Lowest Price**: $${master.lowest_price.toFixed(2)}`);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
if (meta.length) sections.push(`${meta.join("\n")}\n`);
|
|
234
|
+
|
|
235
|
+
// Tracklist
|
|
236
|
+
if (master.tracklist?.length) {
|
|
237
|
+
sections.push("## Tracklist\n");
|
|
238
|
+
const tracks = master.tracklist.map(formatTrack);
|
|
239
|
+
sections.push(`${tracks.join("\n")}\n`);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// Notes
|
|
243
|
+
if (master.notes) {
|
|
244
|
+
sections.push("## Notes\n");
|
|
245
|
+
sections.push(`${master.notes}\n`);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
return sections.join("\n");
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
export const handleDiscogs: SpecialHandler = async (url: string, timeout: number): Promise<RenderResult | null> => {
|
|
252
|
+
try {
|
|
253
|
+
const parsed = new URL(url);
|
|
254
|
+
if (!parsed.hostname.includes("discogs.com")) return null;
|
|
255
|
+
|
|
256
|
+
// Match release or master URLs
|
|
257
|
+
// Patterns: /release/{id}, /master/{id}
|
|
258
|
+
// Also handles: /release/{id}-Artist-Title, /master/{id}-Artist-Title
|
|
259
|
+
const releaseMatch = parsed.pathname.match(/\/release\/(\d+)/);
|
|
260
|
+
const masterMatch = parsed.pathname.match(/\/master\/(\d+)/);
|
|
261
|
+
|
|
262
|
+
if (!releaseMatch && !masterMatch) return null;
|
|
263
|
+
|
|
264
|
+
const fetchedAt = new Date().toISOString();
|
|
265
|
+
const isRelease = !!releaseMatch;
|
|
266
|
+
const id = isRelease ? releaseMatch[1] : masterMatch![1];
|
|
267
|
+
|
|
268
|
+
const apiUrl = isRelease ? `https://api.discogs.com/releases/${id}` : `https://api.discogs.com/masters/${id}`;
|
|
269
|
+
|
|
270
|
+
const result = await loadPage(apiUrl, {
|
|
271
|
+
timeout,
|
|
272
|
+
headers: {
|
|
273
|
+
Accept: "application/json",
|
|
274
|
+
"User-Agent": "CodingAgent/1.0 +https://github.com/can1357/oh-my-pi",
|
|
275
|
+
},
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
if (!result.ok) return null;
|
|
279
|
+
|
|
280
|
+
let md: string;
|
|
281
|
+
if (isRelease) {
|
|
282
|
+
const release = JSON.parse(result.content) as DiscogsRelease;
|
|
283
|
+
md = buildReleaseMarkdown(release);
|
|
284
|
+
} else {
|
|
285
|
+
const master = JSON.parse(result.content) as DiscogsMaster;
|
|
286
|
+
md = buildMasterMarkdown(master);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
const output = finalizeOutput(md);
|
|
290
|
+
return {
|
|
291
|
+
url,
|
|
292
|
+
finalUrl: url,
|
|
293
|
+
contentType: "text/markdown",
|
|
294
|
+
method: "discogs",
|
|
295
|
+
content: output.content,
|
|
296
|
+
fetchedAt,
|
|
297
|
+
truncated: output.truncated,
|
|
298
|
+
notes: [`Fetched via Discogs API (${isRelease ? "release" : "master"})`],
|
|
299
|
+
};
|
|
300
|
+
} catch {}
|
|
301
|
+
|
|
302
|
+
return null;
|
|
303
|
+
};
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import type { RenderResult, SpecialHandler } from "./types";
|
|
2
|
+
import { finalizeOutput, formatCount, loadPage } from "./types";
|
|
3
|
+
|
|
4
|
+
interface DockerHubRepo {
|
|
5
|
+
name: string;
|
|
6
|
+
namespace: string;
|
|
7
|
+
description?: string;
|
|
8
|
+
star_count?: number;
|
|
9
|
+
pull_count?: number;
|
|
10
|
+
last_updated?: string;
|
|
11
|
+
is_official?: boolean;
|
|
12
|
+
is_automated?: boolean;
|
|
13
|
+
user?: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
interface DockerHubTag {
|
|
17
|
+
name: string;
|
|
18
|
+
last_updated?: string;
|
|
19
|
+
full_size?: number;
|
|
20
|
+
digest?: string;
|
|
21
|
+
images?: Array<{
|
|
22
|
+
architecture?: string;
|
|
23
|
+
os?: string;
|
|
24
|
+
size?: number;
|
|
25
|
+
}>;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
interface DockerHubTagsResponse {
|
|
29
|
+
results?: DockerHubTag[];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function formatSize(bytes: number): string {
|
|
33
|
+
if (bytes >= 1_000_000_000) return `${(bytes / 1_000_000_000).toFixed(1)}GB`;
|
|
34
|
+
if (bytes >= 1_000_000) return `${(bytes / 1_000_000).toFixed(1)}MB`;
|
|
35
|
+
if (bytes >= 1_000) return `${(bytes / 1_000).toFixed(1)}KB`;
|
|
36
|
+
return `${bytes}B`;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Handle Docker Hub URLs via API
|
|
41
|
+
*/
|
|
42
|
+
export const handleDockerHub: SpecialHandler = async (url: string, timeout: number): Promise<RenderResult | null> => {
|
|
43
|
+
try {
|
|
44
|
+
const parsed = new URL(url);
|
|
45
|
+
if (!parsed.hostname.includes("hub.docker.com")) return null;
|
|
46
|
+
|
|
47
|
+
let namespace: string;
|
|
48
|
+
let repository: string;
|
|
49
|
+
|
|
50
|
+
// Official images: /_ /{image}
|
|
51
|
+
const officialMatch = parsed.pathname.match(/^\/_\/([^/]+)/);
|
|
52
|
+
if (officialMatch) {
|
|
53
|
+
namespace = "library";
|
|
54
|
+
repository = officialMatch[1];
|
|
55
|
+
} else {
|
|
56
|
+
// Regular images: /r/{namespace}/{repository}
|
|
57
|
+
const repoMatch = parsed.pathname.match(/^\/r\/([^/]+)\/([^/]+)/);
|
|
58
|
+
if (!repoMatch) return null;
|
|
59
|
+
namespace = repoMatch[1];
|
|
60
|
+
repository = repoMatch[2];
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const fetchedAt = new Date().toISOString();
|
|
64
|
+
|
|
65
|
+
// Fetch repository info and tags in parallel
|
|
66
|
+
const repoUrl = `https://hub.docker.com/v2/repositories/${namespace}/${repository}/`;
|
|
67
|
+
const tagsUrl = `https://hub.docker.com/v2/repositories/${namespace}/${repository}/tags/?page_size=10`;
|
|
68
|
+
|
|
69
|
+
const [repoResult, tagsResult] = await Promise.all([
|
|
70
|
+
loadPage(repoUrl, { timeout, headers: { Accept: "application/json" } }),
|
|
71
|
+
loadPage(tagsUrl, { timeout: Math.min(timeout, 10), headers: { Accept: "application/json" } }),
|
|
72
|
+
]);
|
|
73
|
+
|
|
74
|
+
if (!repoResult.ok) return null;
|
|
75
|
+
|
|
76
|
+
let repo: DockerHubRepo;
|
|
77
|
+
try {
|
|
78
|
+
repo = JSON.parse(repoResult.content);
|
|
79
|
+
} catch {
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Parse tags
|
|
84
|
+
let tags: DockerHubTag[] = [];
|
|
85
|
+
if (tagsResult.ok) {
|
|
86
|
+
try {
|
|
87
|
+
const tagsData = JSON.parse(tagsResult.content) as DockerHubTagsResponse;
|
|
88
|
+
tags = tagsData.results ?? [];
|
|
89
|
+
} catch {}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Build markdown output
|
|
93
|
+
const fullName = namespace === "library" ? repo.name : `${namespace}/${repo.name}`;
|
|
94
|
+
let md = `# ${fullName}\n\n`;
|
|
95
|
+
|
|
96
|
+
if (repo.description) {
|
|
97
|
+
md += `${repo.description}\n\n`;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Stats line
|
|
101
|
+
const stats: string[] = [];
|
|
102
|
+
if (repo.pull_count !== undefined) stats.push(`**Pulls:** ${formatCount(repo.pull_count)}`);
|
|
103
|
+
if (repo.star_count !== undefined) stats.push(`**Stars:** ${formatCount(repo.star_count)}`);
|
|
104
|
+
if (repo.is_official) stats.push("**Official Image**");
|
|
105
|
+
if (repo.is_automated) stats.push("**Automated Build**");
|
|
106
|
+
if (stats.length > 0) {
|
|
107
|
+
md += `${stats.join(" · ")}\n`;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if (repo.last_updated) {
|
|
111
|
+
const date = new Date(repo.last_updated);
|
|
112
|
+
md += `**Last Updated:** ${date.toISOString().split("T")[0]}\n`;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
md += "\n";
|
|
116
|
+
|
|
117
|
+
// Docker pull command
|
|
118
|
+
md += "## Quick Start\n\n";
|
|
119
|
+
md += "```bash\n";
|
|
120
|
+
md += `docker pull ${fullName}\n`;
|
|
121
|
+
md += "```\n\n";
|
|
122
|
+
|
|
123
|
+
// Tags
|
|
124
|
+
if (tags.length > 0) {
|
|
125
|
+
md += "## Recent Tags\n\n";
|
|
126
|
+
md += "| Tag | Size | Architectures | Updated |\n";
|
|
127
|
+
md += "|-----|------|---------------|--------|\n";
|
|
128
|
+
|
|
129
|
+
for (const tag of tags) {
|
|
130
|
+
const size = tag.full_size ? formatSize(tag.full_size) : "-";
|
|
131
|
+
const archs =
|
|
132
|
+
tag.images
|
|
133
|
+
?.map((img) => img.architecture)
|
|
134
|
+
.filter(Boolean)
|
|
135
|
+
.join(", ") || "-";
|
|
136
|
+
const updated = tag.last_updated ? new Date(tag.last_updated).toISOString().split("T")[0] : "-";
|
|
137
|
+
md += `| \`${tag.name}\` | ${size} | ${archs} | ${updated} |\n`;
|
|
138
|
+
}
|
|
139
|
+
md += "\n";
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const output = finalizeOutput(md);
|
|
143
|
+
return {
|
|
144
|
+
url,
|
|
145
|
+
finalUrl: url,
|
|
146
|
+
contentType: "text/markdown",
|
|
147
|
+
method: "dockerhub",
|
|
148
|
+
content: output.content,
|
|
149
|
+
fetchedAt,
|
|
150
|
+
truncated: output.truncated,
|
|
151
|
+
notes: ["Fetched via Docker Hub API"],
|
|
152
|
+
};
|
|
153
|
+
} catch {}
|
|
154
|
+
|
|
155
|
+
return null;
|
|
156
|
+
};
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test";
|
|
2
|
+
import { handleMDN } from "./mdn";
|
|
3
|
+
import { handleReadTheDocs } from "./readthedocs";
|
|
4
|
+
|
|
5
|
+
const SKIP = !process.env.WEB_FETCH_INTEGRATION;
|
|
6
|
+
|
|
7
|
+
describe.skipIf(SKIP)("handleMDN", () => {
|
|
8
|
+
it("returns null for non-MDN URLs", async () => {
|
|
9
|
+
const result = await handleMDN("https://example.com", 20);
|
|
10
|
+
expect(result).toBeNull();
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it("returns null for non-docs MDN URLs", async () => {
|
|
14
|
+
const result = await handleMDN("https://developer.mozilla.org/en-US/", 20);
|
|
15
|
+
expect(result).toBeNull();
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it("returns null for MDN blog URLs", async () => {
|
|
19
|
+
const result = await handleMDN("https://developer.mozilla.org/en-US/blog/", 20);
|
|
20
|
+
expect(result).toBeNull();
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("fetches Array.map documentation", async () => {
|
|
24
|
+
const result = await handleMDN(
|
|
25
|
+
"https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/map",
|
|
26
|
+
20,
|
|
27
|
+
);
|
|
28
|
+
expect(result).not.toBeNull();
|
|
29
|
+
expect(result?.method).toBe("mdn");
|
|
30
|
+
expect(result?.content).toContain("map");
|
|
31
|
+
expect(result?.contentType).toBe("text/markdown");
|
|
32
|
+
expect(result?.fetchedAt).toBeTruthy();
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("fetches Promise documentation", async () => {
|
|
36
|
+
const result = await handleMDN(
|
|
37
|
+
"https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise",
|
|
38
|
+
20,
|
|
39
|
+
);
|
|
40
|
+
expect(result).not.toBeNull();
|
|
41
|
+
expect(result?.method).toBe("mdn");
|
|
42
|
+
expect(result?.content).toContain("Promise");
|
|
43
|
+
expect(result?.truncated).toBeDefined();
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("fetches CSS documentation", async () => {
|
|
47
|
+
const result = await handleMDN("https://developer.mozilla.org/en-US/docs/Web/CSS/display", 20);
|
|
48
|
+
expect(result).not.toBeNull();
|
|
49
|
+
expect(result?.method).toBe("mdn");
|
|
50
|
+
expect(result?.content).toContain("display");
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
describe.skipIf(SKIP)("handleReadTheDocs", () => {
|
|
55
|
+
it("returns null for non-RTD URLs", async () => {
|
|
56
|
+
const result = await handleReadTheDocs("https://example.com", 20);
|
|
57
|
+
expect(result).toBeNull();
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("returns null for github.com URLs", async () => {
|
|
61
|
+
const result = await handleReadTheDocs("https://github.com/user/repo", 20);
|
|
62
|
+
expect(result).toBeNull();
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("fetches requests docs", async () => {
|
|
66
|
+
const result = await handleReadTheDocs("https://requests.readthedocs.io/en/latest/", 20);
|
|
67
|
+
expect(result).not.toBeNull();
|
|
68
|
+
expect(result?.method).toBe("readthedocs");
|
|
69
|
+
expect(result?.fetchedAt).toBeTruthy();
|
|
70
|
+
expect(result?.truncated).toBeDefined();
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("returns null for non-readthedocs sites", async () => {
|
|
74
|
+
// These sites use Sphinx/RTD theme but aren't hosted on readthedocs.io
|
|
75
|
+
expect(await handleReadTheDocs("https://www.sphinx-doc.org/en/master/", 20)).toBeNull();
|
|
76
|
+
expect(await handleReadTheDocs("https://docs.pytest.org/en/stable/", 20)).toBeNull();
|
|
77
|
+
expect(await handleReadTheDocs("https://pip.pypa.io/en/stable/", 20)).toBeNull();
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("handles readthedocs.io subdomain", async () => {
|
|
81
|
+
const result = await handleReadTheDocs("https://flask.palletsprojects.readthedocs.io/en/latest/", 20);
|
|
82
|
+
expect(result).not.toBeNull();
|
|
83
|
+
expect(result?.method).toBe("readthedocs");
|
|
84
|
+
});
|
|
85
|
+
});
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test";
|
|
2
|
+
import { handleArtifactHub } from "./artifacthub";
|
|
3
|
+
import { handleCoinGecko } from "./coingecko";
|
|
4
|
+
import { handleDiscogs } from "./discogs";
|
|
5
|
+
|
|
6
|
+
const SKIP = !process.env.WEB_FETCH_INTEGRATION;
|
|
7
|
+
|
|
8
|
+
describe.skipIf(SKIP)("handleCoinGecko", () => {
|
|
9
|
+
it("returns null for non-CoinGecko URLs", async () => {
|
|
10
|
+
const result = await handleCoinGecko("https://example.com", 20);
|
|
11
|
+
expect(result).toBeNull();
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it("returns null for CoinGecko homepage", async () => {
|
|
15
|
+
const result = await handleCoinGecko("https://www.coingecko.com/", 20);
|
|
16
|
+
expect(result).toBeNull();
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it("returns null for CoinGecko categories page", async () => {
|
|
20
|
+
const result = await handleCoinGecko("https://www.coingecko.com/en/categories", 20);
|
|
21
|
+
expect(result).toBeNull();
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("fetches Bitcoin data", async () => {
|
|
25
|
+
const result = await handleCoinGecko("https://www.coingecko.com/en/coins/bitcoin", 20);
|
|
26
|
+
expect(result).not.toBeNull();
|
|
27
|
+
expect(result?.method).toBe("coingecko");
|
|
28
|
+
expect(result?.content).toContain("Bitcoin");
|
|
29
|
+
expect(result?.content).toContain("BTC");
|
|
30
|
+
expect(result?.content).toContain("Price");
|
|
31
|
+
expect(result?.contentType).toBe("text/markdown");
|
|
32
|
+
expect(result?.fetchedAt).toBeTruthy();
|
|
33
|
+
expect(result?.truncated).toBeDefined();
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("fetches Ethereum data", async () => {
|
|
37
|
+
const result = await handleCoinGecko("https://www.coingecko.com/en/coins/ethereum", 20);
|
|
38
|
+
expect(result).not.toBeNull();
|
|
39
|
+
expect(result?.method).toBe("coingecko");
|
|
40
|
+
expect(result?.content).toContain("Ethereum");
|
|
41
|
+
expect(result?.content).toContain("ETH");
|
|
42
|
+
expect(result?.content).toContain("Market Cap");
|
|
43
|
+
expect(result?.truncated).toBeDefined();
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("handles URL without locale prefix", async () => {
|
|
47
|
+
const result = await handleCoinGecko("https://www.coingecko.com/coins/bitcoin", 20);
|
|
48
|
+
expect(result).not.toBeNull();
|
|
49
|
+
expect(result?.method).toBe("coingecko");
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
describe.skipIf(SKIP)("handleDiscogs", () => {
|
|
54
|
+
it("returns null for non-Discogs URLs", async () => {
|
|
55
|
+
const result = await handleDiscogs("https://example.com", 20);
|
|
56
|
+
expect(result).toBeNull();
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("returns null for Discogs homepage", async () => {
|
|
60
|
+
const result = await handleDiscogs("https://www.discogs.com/", 20);
|
|
61
|
+
expect(result).toBeNull();
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("returns null for Discogs search page", async () => {
|
|
65
|
+
const result = await handleDiscogs("https://www.discogs.com/search/", 20);
|
|
66
|
+
expect(result).toBeNull();
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("fetches Daft Punk Discovery release", async () => {
|
|
70
|
+
// Release 249504: Daft Punk - Discovery
|
|
71
|
+
const result = await handleDiscogs("https://www.discogs.com/release/249504-Daft-Punk-Discovery", 20);
|
|
72
|
+
expect(result).not.toBeNull();
|
|
73
|
+
expect(result?.method).toBe("discogs");
|
|
74
|
+
expect(result?.content).toContain("Daft Punk");
|
|
75
|
+
expect(result?.content).toContain("Discovery");
|
|
76
|
+
expect(result?.content).toContain("Tracklist");
|
|
77
|
+
expect(result?.contentType).toBe("text/markdown");
|
|
78
|
+
expect(result?.fetchedAt).toBeTruthy();
|
|
79
|
+
expect(result?.truncated).toBeDefined();
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("fetches master release", async () => {
|
|
83
|
+
// Master 33395: Daft Punk - Discovery (master)
|
|
84
|
+
const result = await handleDiscogs("https://www.discogs.com/master/33395-Daft-Punk-Discovery", 20);
|
|
85
|
+
expect(result).not.toBeNull();
|
|
86
|
+
expect(result?.method).toBe("discogs");
|
|
87
|
+
expect(result?.content).toContain("Daft Punk");
|
|
88
|
+
expect(result?.content).toContain("Master Release");
|
|
89
|
+
expect(result?.truncated).toBeDefined();
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("handles release URL with just ID", async () => {
|
|
93
|
+
const result = await handleDiscogs("https://www.discogs.com/release/249504", 20);
|
|
94
|
+
expect(result).not.toBeNull();
|
|
95
|
+
expect(result?.method).toBe("discogs");
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
describe.skipIf(SKIP)("handleArtifactHub", () => {
|
|
100
|
+
it("returns null for non-ArtifactHub URLs", async () => {
|
|
101
|
+
const result = await handleArtifactHub("https://example.com", 20);
|
|
102
|
+
expect(result).toBeNull();
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it("returns null for ArtifactHub homepage", async () => {
|
|
106
|
+
const result = await handleArtifactHub("https://artifacthub.io/", 20);
|
|
107
|
+
expect(result).toBeNull();
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("returns null for ArtifactHub search page", async () => {
|
|
111
|
+
const result = await handleArtifactHub("https://artifacthub.io/packages/search", 20);
|
|
112
|
+
expect(result).toBeNull();
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it("fetches bitnami/nginx helm chart", async () => {
|
|
116
|
+
const result = await handleArtifactHub("https://artifacthub.io/packages/helm/bitnami/nginx", 20);
|
|
117
|
+
expect(result).not.toBeNull();
|
|
118
|
+
expect(result?.method).toBe("artifacthub");
|
|
119
|
+
expect(result?.content).toContain("nginx");
|
|
120
|
+
expect(result?.content).toContain("Helm Chart");
|
|
121
|
+
expect(result?.content).toContain("Version");
|
|
122
|
+
expect(result?.contentType).toBe("text/markdown");
|
|
123
|
+
expect(result?.fetchedAt).toBeTruthy();
|
|
124
|
+
expect(result?.truncated).toBeDefined();
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it("fetches prometheus-community/prometheus helm chart", async () => {
|
|
128
|
+
const result = await handleArtifactHub(
|
|
129
|
+
"https://artifacthub.io/packages/helm/prometheus-community/prometheus",
|
|
130
|
+
20,
|
|
131
|
+
);
|
|
132
|
+
expect(result).not.toBeNull();
|
|
133
|
+
expect(result?.method).toBe("artifacthub");
|
|
134
|
+
expect(result?.content).toContain("prometheus");
|
|
135
|
+
expect(result?.content).toContain("Repository");
|
|
136
|
+
expect(result?.truncated).toBeDefined();
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it("handles www subdomain", async () => {
|
|
140
|
+
const result = await handleArtifactHub("https://www.artifacthub.io/packages/helm/bitnami/nginx", 20);
|
|
141
|
+
expect(result).not.toBeNull();
|
|
142
|
+
expect(result?.method).toBe("artifacthub");
|
|
143
|
+
});
|
|
144
|
+
});
|