@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,192 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test";
|
|
2
|
+
import { handleBluesky } from "./bluesky";
|
|
3
|
+
import { handleMastodon } from "./mastodon";
|
|
4
|
+
|
|
5
|
+
const SKIP = !process.env.WEB_FETCH_INTEGRATION;
|
|
6
|
+
|
|
7
|
+
describe.skipIf(SKIP)("handleMastodon", () => {
|
|
8
|
+
it("returns null for non-Mastodon URLs", async () => {
|
|
9
|
+
const result = await handleMastodon("https://example.com", 20);
|
|
10
|
+
expect(result).toBeNull();
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it("returns null for URLs without @user pattern", async () => {
|
|
14
|
+
const result = await handleMastodon("https://mastodon.social/about", 20);
|
|
15
|
+
expect(result).toBeNull();
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it(
|
|
19
|
+
"fetches a Mastodon profile",
|
|
20
|
+
async () => {
|
|
21
|
+
// @Gargron is Eugen Rochko, creator of Mastodon - very stable
|
|
22
|
+
const result = await handleMastodon("https://mastodon.social/@Gargron", 20);
|
|
23
|
+
expect(result).not.toBeNull();
|
|
24
|
+
expect(result?.method).toBe("mastodon");
|
|
25
|
+
expect(result?.contentType).toBe("text/markdown");
|
|
26
|
+
expect(result?.content).toContain("Gargron");
|
|
27
|
+
expect(result?.content).toContain("@Gargron");
|
|
28
|
+
expect(result?.content).toContain("**Followers:**");
|
|
29
|
+
expect(result?.content).toContain("**Following:**");
|
|
30
|
+
expect(result?.content).toContain("**Posts:**");
|
|
31
|
+
expect(result?.fetchedAt).toBeTruthy();
|
|
32
|
+
expect(result?.truncated).toBeDefined();
|
|
33
|
+
expect(result?.notes?.[0]).toContain("Mastodon API");
|
|
34
|
+
},
|
|
35
|
+
{ timeout: 30000 },
|
|
36
|
+
);
|
|
37
|
+
|
|
38
|
+
it(
|
|
39
|
+
"fetches a Mastodon post",
|
|
40
|
+
async () => {
|
|
41
|
+
// Gargron's post ID 1 - the first ever Mastodon post
|
|
42
|
+
const result = await handleMastodon("https://mastodon.social/@Gargron/1", 20);
|
|
43
|
+
// Post 1 may not exist anymore; check gracefully
|
|
44
|
+
if (result !== null) {
|
|
45
|
+
expect(result.method).toBe("mastodon");
|
|
46
|
+
expect(result.contentType).toBe("text/markdown");
|
|
47
|
+
expect(result.content).toContain("Post by");
|
|
48
|
+
expect(result.content).toContain("@Gargron");
|
|
49
|
+
expect(result.fetchedAt).toBeTruthy();
|
|
50
|
+
expect(result.truncated).toBeDefined();
|
|
51
|
+
expect(result.notes?.[0]).toContain("Mastodon API");
|
|
52
|
+
}
|
|
53
|
+
},
|
|
54
|
+
{ timeout: 30000 },
|
|
55
|
+
);
|
|
56
|
+
|
|
57
|
+
it(
|
|
58
|
+
"handles a stable pinned post",
|
|
59
|
+
async () => {
|
|
60
|
+
// Use a well-known post from mastodon.social - Gargron's announcement post
|
|
61
|
+
const result = await handleMastodon("https://mastodon.social/@Gargron/109318821117356215", 20);
|
|
62
|
+
// May not exist, check gracefully
|
|
63
|
+
if (result !== null) {
|
|
64
|
+
expect(result.method).toBe("mastodon");
|
|
65
|
+
expect(result.contentType).toBe("text/markdown");
|
|
66
|
+
expect(result.content).toContain("@Gargron");
|
|
67
|
+
expect(result.content).toContain("replies");
|
|
68
|
+
expect(result.content).toContain("boosts");
|
|
69
|
+
expect(result.content).toContain("favorites");
|
|
70
|
+
expect(result.fetchedAt).toBeTruthy();
|
|
71
|
+
}
|
|
72
|
+
},
|
|
73
|
+
{ timeout: 30000 },
|
|
74
|
+
);
|
|
75
|
+
|
|
76
|
+
it(
|
|
77
|
+
"includes recent posts in profile",
|
|
78
|
+
async () => {
|
|
79
|
+
const result = await handleMastodon("https://mastodon.social/@Gargron", 20);
|
|
80
|
+
expect(result).not.toBeNull();
|
|
81
|
+
// May include recent posts section
|
|
82
|
+
if (result?.content?.includes("## Recent Posts")) {
|
|
83
|
+
expect(result.content).toMatch(/###\s+\w+/); // Date header
|
|
84
|
+
}
|
|
85
|
+
},
|
|
86
|
+
{ timeout: 30000 },
|
|
87
|
+
);
|
|
88
|
+
|
|
89
|
+
it("returns null for non-Mastodon instance with @user pattern", async () => {
|
|
90
|
+
// A site that has @user pattern but isn't Mastodon
|
|
91
|
+
const result = await handleMastodon("https://twitter.com/@jack", 20);
|
|
92
|
+
expect(result).toBeNull();
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
describe.skipIf(SKIP)("handleBluesky", () => {
|
|
97
|
+
it("returns null for non-Bluesky URLs", async () => {
|
|
98
|
+
const result = await handleBluesky("https://example.com", 20);
|
|
99
|
+
expect(result).toBeNull();
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("returns null for bsky.app URLs without profile path", async () => {
|
|
103
|
+
const result = await handleBluesky("https://bsky.app/about", 20);
|
|
104
|
+
expect(result).toBeNull();
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it(
|
|
108
|
+
"fetches a Bluesky profile",
|
|
109
|
+
async () => {
|
|
110
|
+
// bsky.app official account - stable
|
|
111
|
+
const result = await handleBluesky("https://bsky.app/profile/bsky.app", 20);
|
|
112
|
+
expect(result).not.toBeNull();
|
|
113
|
+
expect(result?.method).toBe("bluesky-api");
|
|
114
|
+
expect(result?.contentType).toBe("text/markdown");
|
|
115
|
+
expect(result?.content).toContain("bsky.app");
|
|
116
|
+
expect(result?.content).toContain("@bsky.app");
|
|
117
|
+
expect(result?.content).toContain("**Followers:**");
|
|
118
|
+
expect(result?.content).toContain("**Following:**");
|
|
119
|
+
expect(result?.content).toContain("**Posts:**");
|
|
120
|
+
expect(result?.content).toContain("**DID:**");
|
|
121
|
+
expect(result?.fetchedAt).toBeTruthy();
|
|
122
|
+
expect(result?.truncated).toBeDefined();
|
|
123
|
+
expect(result?.notes).toContain("Fetched via AT Protocol API");
|
|
124
|
+
},
|
|
125
|
+
{ timeout: 30000 },
|
|
126
|
+
);
|
|
127
|
+
|
|
128
|
+
it(
|
|
129
|
+
"fetches Jay Graber's profile",
|
|
130
|
+
async () => {
|
|
131
|
+
// Jay Graber - CEO of Bluesky, very stable
|
|
132
|
+
const result = await handleBluesky("https://bsky.app/profile/jay.bsky.team", 20);
|
|
133
|
+
expect(result).not.toBeNull();
|
|
134
|
+
expect(result?.method).toBe("bluesky-api");
|
|
135
|
+
expect(result?.contentType).toBe("text/markdown");
|
|
136
|
+
expect(result?.content).toContain("@jay.bsky.team");
|
|
137
|
+
expect(result?.content).toContain("**Followers:**");
|
|
138
|
+
expect(result?.fetchedAt).toBeTruthy();
|
|
139
|
+
expect(result?.truncated).toBeDefined();
|
|
140
|
+
},
|
|
141
|
+
{ timeout: 30000 },
|
|
142
|
+
);
|
|
143
|
+
|
|
144
|
+
it(
|
|
145
|
+
"fetches a Bluesky post",
|
|
146
|
+
async () => {
|
|
147
|
+
// A post from bsky.app - use a well-known stable post
|
|
148
|
+
const result = await handleBluesky("https://bsky.app/profile/bsky.app/post/3juzlwllznd24", 20);
|
|
149
|
+
// Post may not exist, check gracefully
|
|
150
|
+
if (result !== null) {
|
|
151
|
+
expect(result.method).toBe("bluesky-api");
|
|
152
|
+
expect(result.contentType).toBe("text/markdown");
|
|
153
|
+
expect(result.content).toContain("# Bluesky Post");
|
|
154
|
+
expect(result.content).toContain("@bsky.app");
|
|
155
|
+
expect(result.fetchedAt).toBeTruthy();
|
|
156
|
+
expect(result.truncated).toBeDefined();
|
|
157
|
+
expect(result.notes?.[0]).toContain("AT URI");
|
|
158
|
+
}
|
|
159
|
+
},
|
|
160
|
+
{ timeout: 30000 },
|
|
161
|
+
);
|
|
162
|
+
|
|
163
|
+
it(
|
|
164
|
+
"includes post stats",
|
|
165
|
+
async () => {
|
|
166
|
+
const result = await handleBluesky("https://bsky.app/profile/bsky.app/post/3juzlwllznd24", 20);
|
|
167
|
+
// Stats include likes, reposts, replies
|
|
168
|
+
if (result?.content) {
|
|
169
|
+
// Should have some engagement markers
|
|
170
|
+
const hasStats =
|
|
171
|
+
result.content.includes("❤️") || result.content.includes("🔁") || result.content.includes("💬");
|
|
172
|
+
expect(hasStats || result.content.includes("# Bluesky Post")).toBe(true);
|
|
173
|
+
}
|
|
174
|
+
},
|
|
175
|
+
{ timeout: 30000 },
|
|
176
|
+
);
|
|
177
|
+
|
|
178
|
+
it(
|
|
179
|
+
"handles www.bsky.app URLs",
|
|
180
|
+
async () => {
|
|
181
|
+
const result = await handleBluesky("https://www.bsky.app/profile/bsky.app", 20);
|
|
182
|
+
expect(result).not.toBeNull();
|
|
183
|
+
expect(result?.method).toBe("bluesky-api");
|
|
184
|
+
},
|
|
185
|
+
{ timeout: 30000 },
|
|
186
|
+
);
|
|
187
|
+
|
|
188
|
+
it("returns null for invalid profile handle", async () => {
|
|
189
|
+
const result = await handleBluesky("https://bsky.app/profile/", 20);
|
|
190
|
+
expect(result).toBeNull();
|
|
191
|
+
});
|
|
192
|
+
});
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test";
|
|
2
|
+
import { handleReddit } from "./reddit";
|
|
3
|
+
import { handleStackOverflow } from "./stackoverflow";
|
|
4
|
+
import { handleTwitter } from "./twitter";
|
|
5
|
+
|
|
6
|
+
const SKIP = !process.env.WEB_FETCH_INTEGRATION;
|
|
7
|
+
|
|
8
|
+
describe.skipIf(SKIP)("handleTwitter", () => {
|
|
9
|
+
it("returns null for non-Twitter URLs", async () => {
|
|
10
|
+
const result = await handleTwitter("https://example.com", 10);
|
|
11
|
+
expect(result).toBeNull();
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it(
|
|
15
|
+
"handles twitter.com status URLs",
|
|
16
|
+
async () => {
|
|
17
|
+
const result = await handleTwitter("https://twitter.com/jack/status/20", 10000);
|
|
18
|
+
expect(result).not.toBeNull();
|
|
19
|
+
expect(result?.method).toMatch(/^twitter/);
|
|
20
|
+
expect(result?.contentType).toMatch(/^text\/(markdown|plain)$/);
|
|
21
|
+
// Either successful fetch or blocked/unavailable message
|
|
22
|
+
if (result?.method === "twitter-nitter") {
|
|
23
|
+
expect(result?.content).toContain("Tweet by");
|
|
24
|
+
expect(result?.notes?.[0]).toContain("Via Nitter");
|
|
25
|
+
} else if (result?.method === "twitter-blocked") {
|
|
26
|
+
expect(result?.content).toContain("blocks automated access");
|
|
27
|
+
expect(result?.notes?.[0]).toContain("Nitter instances unavailable");
|
|
28
|
+
}
|
|
29
|
+
},
|
|
30
|
+
{ timeout: 30000 },
|
|
31
|
+
);
|
|
32
|
+
|
|
33
|
+
it(
|
|
34
|
+
"handles x.com status URLs",
|
|
35
|
+
async () => {
|
|
36
|
+
const result = await handleTwitter("https://x.com/elonmusk/status/1", 10000);
|
|
37
|
+
expect(result).not.toBeNull();
|
|
38
|
+
expect(result?.method).toMatch(/^twitter/);
|
|
39
|
+
expect(result?.contentType).toMatch(/^text\/(markdown|plain)$/);
|
|
40
|
+
// Either successful fetch or blocked/unavailable message
|
|
41
|
+
if (result?.method === "twitter-nitter") {
|
|
42
|
+
expect(result?.finalUrl).toContain("nitter");
|
|
43
|
+
} else if (result?.method === "twitter-blocked") {
|
|
44
|
+
expect(result?.content).toContain("blocks automated access");
|
|
45
|
+
}
|
|
46
|
+
},
|
|
47
|
+
{ timeout: 30000 },
|
|
48
|
+
);
|
|
49
|
+
|
|
50
|
+
it(
|
|
51
|
+
"handles www.twitter.com URLs",
|
|
52
|
+
async () => {
|
|
53
|
+
const result = await handleTwitter("https://www.twitter.com/twitter/status/1", 10000);
|
|
54
|
+
expect(result).not.toBeNull();
|
|
55
|
+
expect(result?.method).toMatch(/^twitter/);
|
|
56
|
+
},
|
|
57
|
+
{ timeout: 30000 },
|
|
58
|
+
);
|
|
59
|
+
|
|
60
|
+
it(
|
|
61
|
+
"handles www.x.com URLs",
|
|
62
|
+
async () => {
|
|
63
|
+
const result = await handleTwitter("https://www.x.com/twitter/status/1", 10000);
|
|
64
|
+
expect(result).not.toBeNull();
|
|
65
|
+
expect(result?.method).toMatch(/^twitter/);
|
|
66
|
+
},
|
|
67
|
+
{ timeout: 30000 },
|
|
68
|
+
);
|
|
69
|
+
|
|
70
|
+
it(
|
|
71
|
+
"may fail due to Nitter availability",
|
|
72
|
+
async () => {
|
|
73
|
+
// Test that failure returns helpful message instead of null
|
|
74
|
+
const result = await handleTwitter("https://twitter.com/nonexistent/status/999999999999999999", 10000);
|
|
75
|
+
expect(result).not.toBeNull();
|
|
76
|
+
// Should return blocked message when Nitter fails
|
|
77
|
+
if (result?.method === "twitter-blocked") {
|
|
78
|
+
expect(result?.content).toContain("Nitter instances were unavailable");
|
|
79
|
+
expect(result?.content).toContain("Try:");
|
|
80
|
+
}
|
|
81
|
+
},
|
|
82
|
+
{ timeout: 30000 },
|
|
83
|
+
);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
describe.skipIf(SKIP)("handleReddit", () => {
|
|
87
|
+
it("returns null for non-Reddit URLs", async () => {
|
|
88
|
+
const result = await handleReddit("https://example.com", 10);
|
|
89
|
+
expect(result).toBeNull();
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("fetches subreddit", async () => {
|
|
93
|
+
const result = await handleReddit("https://www.reddit.com/r/programming/", 20000);
|
|
94
|
+
expect(result).not.toBeNull();
|
|
95
|
+
expect(result?.method).toBe("reddit");
|
|
96
|
+
expect(result?.contentType).toBe("text/markdown");
|
|
97
|
+
expect(result?.content).toContain("# r/programming");
|
|
98
|
+
expect(result?.content).toMatch(/\*\*.*\*\*/); // Contains bold formatting
|
|
99
|
+
expect(result?.notes).toContain("Fetched via Reddit JSON API");
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("fetches individual post", async () => {
|
|
103
|
+
// Use a more reliable recent post URL
|
|
104
|
+
const result = await handleReddit("https://www.reddit.com/r/programming/", 20000);
|
|
105
|
+
// Individual post may fail if post doesn't exist, check if we get data
|
|
106
|
+
if (result !== null) {
|
|
107
|
+
expect(result.method).toBe("reddit");
|
|
108
|
+
expect(result.contentType).toBe("text/markdown");
|
|
109
|
+
expect(result.content).toContain("# r/");
|
|
110
|
+
expect(result.notes).toContain("Fetched via Reddit JSON API");
|
|
111
|
+
}
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it("includes comments in post when available", async () => {
|
|
115
|
+
const result = await handleReddit("https://www.reddit.com/r/programming/", 20000);
|
|
116
|
+
// Comments test - just verify structure if post with comments is found
|
|
117
|
+
if (result?.content?.includes("## Top Comments")) {
|
|
118
|
+
expect(result.content).toContain("### u/");
|
|
119
|
+
expect(result.content).toContain("points");
|
|
120
|
+
}
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it("handles old.reddit.com", async () => {
|
|
124
|
+
const result = await handleReddit("https://old.reddit.com/r/programming/", 20000);
|
|
125
|
+
expect(result).not.toBeNull();
|
|
126
|
+
expect(result?.method).toBe("reddit");
|
|
127
|
+
expect(result?.contentType).toBe("text/markdown");
|
|
128
|
+
expect(result?.content).toContain("# r/");
|
|
129
|
+
expect(result?.notes).toContain("Fetched via Reddit JSON API");
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it("handles reddit.com without www", async () => {
|
|
133
|
+
const result = await handleReddit("https://reddit.com/r/programming/", 20000);
|
|
134
|
+
expect(result).not.toBeNull();
|
|
135
|
+
expect(result?.method).toBe("reddit");
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it("handles URLs with query parameters", async () => {
|
|
139
|
+
const result = await handleReddit("https://www.reddit.com/r/programming/?sort=top", 20000);
|
|
140
|
+
expect(result).not.toBeNull();
|
|
141
|
+
expect(result?.method).toBe("reddit");
|
|
142
|
+
expect(result?.content).toContain("# r/");
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it("returns null for malformed Reddit URLs", async () => {
|
|
146
|
+
const result = await handleReddit("https://www.reddit.com/invalid", 20000);
|
|
147
|
+
// May return null or empty result
|
|
148
|
+
if (result !== null) {
|
|
149
|
+
expect(result.content).toBeDefined();
|
|
150
|
+
}
|
|
151
|
+
});
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
describe.skipIf(SKIP)("handleStackOverflow", () => {
|
|
155
|
+
it("returns null for non-SO URLs", async () => {
|
|
156
|
+
const result = await handleStackOverflow("https://example.com", 10);
|
|
157
|
+
expect(result).toBeNull();
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it("returns null for SO URLs without question ID", async () => {
|
|
161
|
+
const result = await handleStackOverflow("https://stackoverflow.com/", 10);
|
|
162
|
+
expect(result).toBeNull();
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it("fetches a known question", async () => {
|
|
166
|
+
// Use a well-known question that definitely exists
|
|
167
|
+
const result = await handleStackOverflow(
|
|
168
|
+
"https://stackoverflow.com/questions/11227809/why-is-processing-a-sorted-array-faster",
|
|
169
|
+
20000,
|
|
170
|
+
);
|
|
171
|
+
// API may fail or rate limit, check gracefully
|
|
172
|
+
if (result !== null) {
|
|
173
|
+
expect(result.method).toBe("stackoverflow");
|
|
174
|
+
expect(result.contentType).toBe("text/markdown");
|
|
175
|
+
expect(result.content).toContain("# ");
|
|
176
|
+
expect(result.content).toContain("**Score:");
|
|
177
|
+
expect(result.content).toContain("**Tags:");
|
|
178
|
+
expect(result.content).toContain("## Question");
|
|
179
|
+
expect(result.notes).toContain("Fetched via Stack Exchange API");
|
|
180
|
+
}
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it("includes answers", async () => {
|
|
184
|
+
const result = await handleStackOverflow(
|
|
185
|
+
"https://stackoverflow.com/questions/11227809/why-is-processing-a-sorted-array-faster",
|
|
186
|
+
20000,
|
|
187
|
+
);
|
|
188
|
+
if (result?.content?.includes("## Answers")) {
|
|
189
|
+
expect(result.content).toContain("### Score:");
|
|
190
|
+
}
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
it("shows accepted answer marker when present", async () => {
|
|
194
|
+
const result = await handleStackOverflow(
|
|
195
|
+
"https://stackoverflow.com/questions/11227809/why-is-processing-a-sorted-array-faster",
|
|
196
|
+
20000,
|
|
197
|
+
);
|
|
198
|
+
// Some questions may have accepted answers
|
|
199
|
+
if (result?.content?.includes("(Accepted)")) {
|
|
200
|
+
expect(result.content).toContain("## Answers");
|
|
201
|
+
}
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it("handles stackoverflow.com", async () => {
|
|
205
|
+
const result = await handleStackOverflow(
|
|
206
|
+
"https://stackoverflow.com/questions/11227809/why-is-processing-a-sorted-array-faster-than-processing-an-unsorted-array",
|
|
207
|
+
20000,
|
|
208
|
+
);
|
|
209
|
+
expect(result).not.toBeNull();
|
|
210
|
+
expect(result?.method).toBe("stackoverflow");
|
|
211
|
+
expect(result?.content).toContain("# ");
|
|
212
|
+
expect(result?.content).toContain("## Question");
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
it("handles other StackExchange sites", async () => {
|
|
216
|
+
const result = await handleStackOverflow("https://math.stackexchange.com/questions/1000/", 20000);
|
|
217
|
+
// API may fail, check gracefully
|
|
218
|
+
if (result !== null) {
|
|
219
|
+
expect(result.method).toBe("stackoverflow");
|
|
220
|
+
expect(result.contentType).toBe("text/markdown");
|
|
221
|
+
expect(result.content).toContain("# ");
|
|
222
|
+
expect(result.notes).toContain("Fetched via Stack Exchange API");
|
|
223
|
+
}
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
it("extracts question ID from URL", async () => {
|
|
227
|
+
const result = await handleStackOverflow(
|
|
228
|
+
"https://stackoverflow.com/questions/1234567/some-long-question-title",
|
|
229
|
+
20000,
|
|
230
|
+
);
|
|
231
|
+
// Should attempt to fetch, may or may not exist
|
|
232
|
+
// Either returns valid result or null
|
|
233
|
+
if (result !== null) {
|
|
234
|
+
expect(result.method).toBe("stackoverflow");
|
|
235
|
+
}
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
it("handles URLs without trailing slash", async () => {
|
|
239
|
+
const result = await handleStackOverflow("https://stackoverflow.com/questions/11227809", 20000);
|
|
240
|
+
// API may fail, check gracefully
|
|
241
|
+
if (result !== null) {
|
|
242
|
+
expect(result.method).toBe("stackoverflow");
|
|
243
|
+
}
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
it("includes question metadata", async () => {
|
|
247
|
+
const result = await handleStackOverflow(
|
|
248
|
+
"https://stackoverflow.com/questions/11227809/why-is-processing-a-sorted-array-faster",
|
|
249
|
+
20000,
|
|
250
|
+
);
|
|
251
|
+
// API may fail, check gracefully
|
|
252
|
+
if (result !== null) {
|
|
253
|
+
expect(result.content).toContain("**Score:");
|
|
254
|
+
expect(result.content).toContain("**Answers:");
|
|
255
|
+
expect(result.content).toContain("**Tags:");
|
|
256
|
+
expect(result.content).toContain("**Asked by:");
|
|
257
|
+
}
|
|
258
|
+
});
|
|
259
|
+
});
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Spotify URL handler for podcasts, tracks, albums, and playlists
|
|
3
|
+
*
|
|
4
|
+
* Uses oEmbed API and Open Graph metadata to extract information
|
|
5
|
+
* from Spotify URLs without requiring authentication.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { SpecialHandler } from "./types";
|
|
9
|
+
import { finalizeOutput, loadPage } from "./types";
|
|
10
|
+
|
|
11
|
+
interface SpotifyOEmbedResponse {
|
|
12
|
+
title?: string;
|
|
13
|
+
thumbnail_url?: string;
|
|
14
|
+
provider_name?: string;
|
|
15
|
+
html?: string;
|
|
16
|
+
width?: number;
|
|
17
|
+
height?: number;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
interface OpenGraphData {
|
|
21
|
+
title?: string;
|
|
22
|
+
description?: string;
|
|
23
|
+
audio?: string;
|
|
24
|
+
image?: string;
|
|
25
|
+
type?: string;
|
|
26
|
+
duration?: string;
|
|
27
|
+
album?: string;
|
|
28
|
+
musician?: string;
|
|
29
|
+
artist?: string;
|
|
30
|
+
releaseDate?: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Parse Open Graph meta tags from HTML
|
|
35
|
+
*/
|
|
36
|
+
function parseOpenGraph(html: string): OpenGraphData {
|
|
37
|
+
const og: OpenGraphData = {};
|
|
38
|
+
|
|
39
|
+
const metaPattern = /<meta\s+(?:property|name)="([^"]+)"\s+content="([^"]*)"[^>]*>/gi;
|
|
40
|
+
let match: RegExpExecArray | null = null;
|
|
41
|
+
|
|
42
|
+
while (true) {
|
|
43
|
+
match = metaPattern.exec(html);
|
|
44
|
+
if (match === null) break;
|
|
45
|
+
const [, property, content] = match;
|
|
46
|
+
|
|
47
|
+
if (property === "og:title") og.title = content;
|
|
48
|
+
else if (property === "og:description") og.description = content;
|
|
49
|
+
else if (property === "og:audio") og.audio = content;
|
|
50
|
+
else if (property === "og:image") og.image = content;
|
|
51
|
+
else if (property === "og:type") og.type = content;
|
|
52
|
+
else if (property === "music:duration") og.duration = content;
|
|
53
|
+
else if (property === "music:album") og.album = content;
|
|
54
|
+
else if (property === "music:musician") og.musician = content;
|
|
55
|
+
else if (property === "music:release_date") og.releaseDate = content;
|
|
56
|
+
else if (property === "twitter:audio:artist_name") og.artist = content;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return og;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Determine content type from URL path
|
|
64
|
+
*/
|
|
65
|
+
function getContentType(url: string): string | null {
|
|
66
|
+
if (url.includes("/episode/")) return "podcast-episode";
|
|
67
|
+
if (url.includes("/show/")) return "podcast-show";
|
|
68
|
+
if (url.includes("/track/")) return "track";
|
|
69
|
+
if (url.includes("/album/")) return "album";
|
|
70
|
+
if (url.includes("/playlist/")) return "playlist";
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Format duration from seconds
|
|
76
|
+
*/
|
|
77
|
+
function formatDuration(seconds: string | undefined): string | null {
|
|
78
|
+
if (!seconds) return null;
|
|
79
|
+
const num = parseInt(seconds, 10);
|
|
80
|
+
if (Number.isNaN(num)) return null;
|
|
81
|
+
|
|
82
|
+
const hours = Math.floor(num / 3600);
|
|
83
|
+
const minutes = Math.floor((num % 3600) / 60);
|
|
84
|
+
const secs = num % 60;
|
|
85
|
+
|
|
86
|
+
if (hours > 0) {
|
|
87
|
+
return `${hours}:${minutes.toString().padStart(2, "0")}:${secs.toString().padStart(2, "0")}`;
|
|
88
|
+
}
|
|
89
|
+
return `${minutes}:${secs.toString().padStart(2, "0")}`;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Format output based on content type and available metadata
|
|
94
|
+
*/
|
|
95
|
+
function formatOutput(contentType: string, oEmbed: SpotifyOEmbedResponse, og: OpenGraphData, url: string): string {
|
|
96
|
+
const sections: string[] = [];
|
|
97
|
+
|
|
98
|
+
// Title
|
|
99
|
+
const title = og.title || oEmbed.title || "Unknown";
|
|
100
|
+
sections.push(`# ${title}\n`);
|
|
101
|
+
|
|
102
|
+
// Type
|
|
103
|
+
sections.push(`**Type**: ${contentType}\n`);
|
|
104
|
+
|
|
105
|
+
// Description
|
|
106
|
+
if (og.description) {
|
|
107
|
+
sections.push(`**Description**: ${og.description}\n`);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Content-specific metadata
|
|
111
|
+
if (contentType === "track" || contentType === "podcast-episode") {
|
|
112
|
+
if (og.artist || og.musician) {
|
|
113
|
+
sections.push(`**Artist**: ${og.artist || og.musician}\n`);
|
|
114
|
+
}
|
|
115
|
+
if (og.album) {
|
|
116
|
+
sections.push(`**Album**: ${og.album}\n`);
|
|
117
|
+
}
|
|
118
|
+
if (og.duration) {
|
|
119
|
+
const formatted = formatDuration(og.duration);
|
|
120
|
+
if (formatted) {
|
|
121
|
+
sections.push(`**Duration**: ${formatted}\n`);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if (contentType === "album" && og.releaseDate) {
|
|
127
|
+
sections.push(`**Release Date**: ${og.releaseDate}\n`);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Note about limited information
|
|
131
|
+
sections.push("\n---\n");
|
|
132
|
+
if (contentType === "playlist") {
|
|
133
|
+
sections.push(
|
|
134
|
+
"**Note**: Playlist details (tracks, creator, follower count) require authentication. " +
|
|
135
|
+
"Only basic metadata is available without Spotify API credentials.\n",
|
|
136
|
+
);
|
|
137
|
+
} else if (contentType === "album") {
|
|
138
|
+
sections.push(
|
|
139
|
+
"**Note**: Track listing and detailed album information require authentication. " +
|
|
140
|
+
"Only basic metadata is available without Spotify API credentials.\n",
|
|
141
|
+
);
|
|
142
|
+
} else if (contentType === "podcast-show") {
|
|
143
|
+
sections.push(
|
|
144
|
+
"**Note**: Episode listing and detailed show information require authentication. " +
|
|
145
|
+
"Only basic metadata is available without Spotify API credentials.\n",
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
sections.push(`**URL**: ${url}\n`);
|
|
150
|
+
|
|
151
|
+
if (oEmbed.thumbnail_url) {
|
|
152
|
+
sections.push(`**Thumbnail**: ${oEmbed.thumbnail_url}\n`);
|
|
153
|
+
} else if (og.image) {
|
|
154
|
+
sections.push(`**Image**: ${og.image}\n`);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
return sections.join("\n");
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export const handleSpotify: SpecialHandler = async (url: string, timeout: number) => {
|
|
161
|
+
// Check if this is a Spotify URL
|
|
162
|
+
if (!url.includes("open.spotify.com/")) {
|
|
163
|
+
return null;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const contentType = getContentType(url);
|
|
167
|
+
if (!contentType) {
|
|
168
|
+
return null;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const notes: string[] = [];
|
|
172
|
+
let oEmbedData: SpotifyOEmbedResponse = {};
|
|
173
|
+
let ogData: OpenGraphData = {};
|
|
174
|
+
|
|
175
|
+
// Fetch oEmbed data
|
|
176
|
+
try {
|
|
177
|
+
const oEmbedUrl = `https://open.spotify.com/oembed?url=${encodeURIComponent(url)}`;
|
|
178
|
+
const response = await loadPage(oEmbedUrl, { timeout });
|
|
179
|
+
|
|
180
|
+
if (response.ok) {
|
|
181
|
+
oEmbedData = JSON.parse(response.content) as SpotifyOEmbedResponse;
|
|
182
|
+
notes.push("Retrieved metadata via Spotify oEmbed API");
|
|
183
|
+
} else {
|
|
184
|
+
notes.push(`oEmbed API returned status ${response.status || "error"}`);
|
|
185
|
+
}
|
|
186
|
+
} catch (err) {
|
|
187
|
+
notes.push(`Failed to fetch oEmbed data: ${err instanceof Error ? err.message : String(err)}`);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// Fetch page HTML for Open Graph metadata
|
|
191
|
+
try {
|
|
192
|
+
const pageResponse = await loadPage(url, { timeout });
|
|
193
|
+
|
|
194
|
+
if (pageResponse.ok) {
|
|
195
|
+
ogData = parseOpenGraph(pageResponse.content);
|
|
196
|
+
notes.push("Parsed Open Graph metadata from page HTML");
|
|
197
|
+
} else {
|
|
198
|
+
notes.push(`Page fetch returned status ${pageResponse.status || "error"}`);
|
|
199
|
+
}
|
|
200
|
+
} catch (err) {
|
|
201
|
+
notes.push(`Failed to fetch page HTML: ${err instanceof Error ? err.message : String(err)}`);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// Format output
|
|
205
|
+
const output = formatOutput(contentType, oEmbedData, ogData, url);
|
|
206
|
+
const { content, truncated } = finalizeOutput(output);
|
|
207
|
+
|
|
208
|
+
return {
|
|
209
|
+
url,
|
|
210
|
+
finalUrl: url,
|
|
211
|
+
contentType: "text/markdown",
|
|
212
|
+
method: "spotify",
|
|
213
|
+
content,
|
|
214
|
+
fetchedAt: new Date().toISOString(),
|
|
215
|
+
truncated,
|
|
216
|
+
notes,
|
|
217
|
+
};
|
|
218
|
+
};
|