@soundchain/openclaw-plugin 1.0.0 → 2.0.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.
@@ -1,406 +0,0 @@
1
- /**
2
- * FURL Content Fetcher — URL Detection + Content Extraction
3
- *
4
- * Detects URLs in DM messages and extracts readable text content
5
- * so Claude can summarize videos, articles, tweets, etc.
6
- *
7
- * Supported:
8
- * - YouTube (transcript via captions API — free, no key needed)
9
- * - General web pages (HTML stripped to text)
10
- *
11
- * Zero cost, zero new dependencies. Uses built-in fetch().
12
- */
13
-
14
- // ---------------------------------------------------------------------------
15
- // URL detection
16
- // ---------------------------------------------------------------------------
17
-
18
- const URL_REGEX = /https?:\/\/[^\s<>"')\]]+/gi;
19
-
20
- const YOUTUBE_REGEX =
21
- /(?:youtube\.com\/(?:watch\?.*?v=|shorts\/|live\/|embed\/)|youtu\.be\/)([a-zA-Z0-9_-]{11})/;
22
-
23
- const X_TWITTER_REGEX = /(?:x\.com|twitter\.com)\/(\w+)\/status\/(\d+)/;
24
-
25
- import { lookup } from "node:dns/promises";
26
-
27
- const USER_AGENT =
28
- "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36";
29
-
30
- const FETCH_TIMEOUT_MS = 12_000;
31
- const MAX_URLS = 3;
32
- const MAX_TRANSCRIPT_CHARS = 15_000;
33
- const MAX_WEBPAGE_CHARS = 10_000;
34
-
35
- // Block private/internal network URLs to prevent SSRF
36
- const BLOCKED_HOST_PATTERNS = [
37
- /^localhost$/i,
38
- /^127\.\d+\.\d+\.\d+$/,
39
- /^10\.\d+\.\d+\.\d+$/,
40
- /^172\.(1[6-9]|2\d|3[01])\.\d+\.\d+$/,
41
- /^192\.168\.\d+\.\d+$/,
42
- /^169\.254\.\d+\.\d+$/, // Link-local
43
- /^0\.0\.0\.0$/,
44
- /^\[?::1?\]?$/,
45
- /^\[?::ffff:/i, // IPv6-mapped IPv4 (bracketed or bare)
46
- /^\[?fd[0-9a-f]{2}:/i, // IPv6 ULA fd00::/8 (bracketed or bare)
47
- /^\[?fe80:/i, // IPv6 link-local fe80::/10 (bracketed or bare)
48
- /^\[?fc[0-9a-f]{2}:/i, // IPv6 ULA fc00::/7 (bracketed or bare)
49
- /\.internal$/i,
50
- /\.local$/i,
51
- /metadata\.google/i, // Cloud metadata
52
- /169\.254\.169\.254/, // AWS/GCP metadata
53
- ];
54
-
55
- function isBlockedHostname(hostname: string): boolean {
56
- return BLOCKED_HOST_PATTERNS.some((p) => p.test(hostname));
57
- }
58
-
59
- function isBlockedUrl(url: string): boolean {
60
- try {
61
- const parsed = new URL(url);
62
- return isBlockedHostname(parsed.hostname);
63
- } catch {
64
- return true; // Block unparseable URLs
65
- }
66
- }
67
-
68
- /** Resolve hostname via DNS and check if the IP is private/blocked */
69
- async function isBlockedAfterResolve(url: string): Promise<boolean> {
70
- if (isBlockedUrl(url)) return true;
71
- try {
72
- const parsed = new URL(url);
73
- const { address } = await lookup(parsed.hostname);
74
- return isBlockedHostname(address);
75
- } catch {
76
- return false; // DNS failure — allow (will fail at fetch anyway)
77
- }
78
- }
79
-
80
- /**
81
- * Safe fetch that blocks redirects to private networks.
82
- * Uses redirect: "manual" to inspect each hop.
83
- */
84
- const MAX_REDIRECTS = 5;
85
-
86
- async function safeFetch(url: string, options?: RequestInit, hops = 0): Promise<Response> {
87
- if (hops > MAX_REDIRECTS) throw new Error(`Too many redirects (>${MAX_REDIRECTS})`);
88
-
89
- // Resolve DNS and validate BEFORE fetching (prevents DNS rebinding)
90
- const parsed = new URL(url);
91
- if (isBlockedHostname(parsed.hostname)) throw new Error("Blocked hostname");
92
- let resolvedIp: string | undefined;
93
- try {
94
- const { address } = await lookup(parsed.hostname);
95
- resolvedIp = address;
96
- if (isBlockedHostname(address)) throw new Error("Blocked resolved IP");
97
- } catch (e: any) {
98
- if (e.message?.includes("Blocked")) throw e;
99
- // DNS failure — proceed with hostname (will fail at fetch if unreachable)
100
- }
101
-
102
- // DNS validated — proceed with original URL (IP pinning breaks TLS/SNI)
103
- // The validation + short timeout window is sufficient for non-targeted SSRF
104
- const res = await fetch(url, {
105
- ...options,
106
- redirect: "manual",
107
- signal: options?.signal ?? AbortSignal.timeout(FETCH_TIMEOUT_MS),
108
- });
109
-
110
- if (res.status >= 300 && res.status < 400) {
111
- const location = res.headers.get("location");
112
- if (!location) return res;
113
-
114
- const redirectUrl = new URL(location, url).toString();
115
- if (isBlockedUrl(redirectUrl)) throw new Error("Redirect to blocked URL");
116
-
117
- return safeFetch(redirectUrl, options, hops + 1);
118
- }
119
-
120
- return res;
121
- }
122
-
123
- // ---------------------------------------------------------------------------
124
- // Public API
125
- // ---------------------------------------------------------------------------
126
-
127
- /**
128
- * Scans a message for URLs, fetches content from each, and appends
129
- * extracted text so Claude can summarize it.
130
- * Returns the original message if no URLs found or all extractions fail.
131
- */
132
- export async function enrichMessageWithUrlContent(message: string): Promise<string> {
133
- const urls = message.match(URL_REGEX);
134
- if (!urls || urls.length === 0) return message;
135
-
136
- // Deduplicate
137
- const unique = [...new Set(urls)].slice(0, MAX_URLS);
138
-
139
- const extractions: string[] = [];
140
-
141
- for (const url of unique) {
142
- try {
143
- const content = await extractContent(url);
144
- if (content) {
145
- extractions.push(content);
146
- }
147
- } catch {
148
- // Skip failed extractions silently
149
- }
150
- }
151
-
152
- if (extractions.length === 0) return message;
153
-
154
- return message + "\n\n---\n" + extractions.join("\n\n---\n");
155
- }
156
-
157
- // ---------------------------------------------------------------------------
158
- // Router
159
- // ---------------------------------------------------------------------------
160
-
161
- async function extractContent(url: string): Promise<string | null> {
162
- // Block private/internal network URLs (SSRF prevention — checks both hostname and resolved IP)
163
- if (await isBlockedAfterResolve(url)) return null;
164
-
165
- const ytMatch = url.match(YOUTUBE_REGEX);
166
- if (ytMatch) {
167
- return fetchYouTubeTranscript(ytMatch[1], url);
168
- }
169
- const xMatch = url.match(X_TWITTER_REGEX);
170
- if (xMatch) {
171
- return fetchXPost(xMatch[1], xMatch[2], url);
172
- }
173
- return fetchWebPageText(url);
174
- }
175
-
176
- // ---------------------------------------------------------------------------
177
- // YouTube Transcript (free — captions API, no key needed)
178
- // ---------------------------------------------------------------------------
179
-
180
- async function fetchYouTubeTranscript(
181
- videoId: string,
182
- originalUrl: string,
183
- ): Promise<string | null> {
184
- try {
185
- const res = await fetch(`https://www.youtube.com/watch?v=${videoId}&hl=en`, {
186
- headers: {
187
- "User-Agent": USER_AGENT,
188
- "Accept-Language": "en-US,en;q=0.9",
189
- },
190
- signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
191
- });
192
- const html = await res.text();
193
-
194
- // Extract video title
195
- const titleMatch = html.match(/<title[^>]*>([\s\S]*?)<\/title>/i);
196
- const title = titleMatch
197
- ? titleMatch[1].replace(/\s*-\s*YouTube\s*$/, "").trim()
198
- : "Unknown Video";
199
-
200
- // Extract ytInitialPlayerResponse JSON
201
- const playerMatch =
202
- html.match(/var\s+ytInitialPlayerResponse\s*=\s*(\{.+?\});/s) ||
203
- html.match(/ytInitialPlayerResponse\s*=\s*(\{.+?\});/s);
204
-
205
- if (!playerMatch) {
206
- return `[YouTube Video: "${title}" — ${originalUrl}]\n(Could not extract transcript — captions may be disabled)`;
207
- }
208
-
209
- let playerResponse: Record<string, unknown>;
210
- try {
211
- playerResponse = JSON.parse(playerMatch[1]);
212
- } catch {
213
- return `[YouTube Video: "${title}" — ${originalUrl}]\n(Could not parse video data)`;
214
- }
215
-
216
- // Navigate to caption tracks
217
- const captions = playerResponse?.captions as Record<string, unknown> | undefined;
218
- const renderer = captions?.playerCaptionsTracklistRenderer as
219
- | Record<string, unknown>
220
- | undefined;
221
- const captionTracks = renderer?.captionTracks as Array<Record<string, unknown>> | undefined;
222
-
223
- if (!captionTracks || captionTracks.length === 0) {
224
- // No captions — try to get video description instead
225
- const descMatch = html.match(/"shortDescription":"((?:[^"\\]|\\.)*)"/);
226
- const description = descMatch
227
- ? descMatch[1].replace(/\\n/g, "\n").replace(/\\"/g, '"').replace(/\\\\/g, "\\")
228
- : null;
229
-
230
- if (description && description.length > 50) {
231
- return `[YouTube Video: "${title}" — ${originalUrl}]\n(No captions available — description below)\n\n${description.slice(0, 3000)}`;
232
- }
233
- return `[YouTube Video: "${title}" — ${originalUrl}]\n(No captions or description available)`;
234
- }
235
-
236
- // Prefer English, fallback to first available
237
- const track =
238
- captionTracks.find(
239
- (t) => typeof t.languageCode === "string" && t.languageCode.startsWith("en"),
240
- ) || captionTracks[0];
241
-
242
- const captionUrl = track?.baseUrl;
243
- if (typeof captionUrl !== "string") return null;
244
-
245
- // Fetch caption XML
246
- const captionRes = await safeFetch(captionUrl, {
247
- signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
248
- });
249
- const xml = await captionRes.text();
250
-
251
- // Parse <text> elements to plain text
252
- const textMatches = [...xml.matchAll(/<text[^>]*>([\s\S]*?)<\/text>/g)];
253
- const transcript = textMatches
254
- .map((m) => decodeHtmlEntities(m[1]).replace(/\n/g, " "))
255
- .join(" ")
256
- .replace(/\s+/g, " ")
257
- .trim();
258
-
259
- if (!transcript) {
260
- return `[YouTube Video: "${title}" — ${originalUrl}]\n(Captions found but empty)`;
261
- }
262
-
263
- const lang = typeof track.languageCode === "string" ? ` (${track.languageCode})` : "";
264
-
265
- return `[YouTube Video: "${title}" — ${originalUrl}]\n[Transcript${lang} — ${transcript.length} chars]:\n\n${transcript.slice(0, MAX_TRANSCRIPT_CHARS)}`;
266
- } catch (err) {
267
- return null;
268
- }
269
- }
270
-
271
- // ---------------------------------------------------------------------------
272
- // X/Twitter Posts (via Nitter instances or syndication API)
273
- // ---------------------------------------------------------------------------
274
-
275
- async function fetchXPost(
276
- username: string,
277
- tweetId: string,
278
- originalUrl: string,
279
- ): Promise<string | null> {
280
- // Try Twitter syndication API (public, no auth needed)
281
- try {
282
- const syndicationUrl = `https://cdn.syndication.twimg.com/tweet-result?id=${tweetId}&token=0`;
283
- const res = await fetch(syndicationUrl, {
284
- headers: { "User-Agent": USER_AGENT },
285
- signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
286
- });
287
-
288
- if (res.ok) {
289
- const data = (await res.json()) as Record<string, unknown>;
290
- const text = data.text as string | undefined;
291
- const userName = (data.user as Record<string, unknown>)?.name as string | undefined;
292
- const handle = (data.user as Record<string, unknown>)?.screen_name as string | undefined;
293
-
294
- if (text) {
295
- const header = `[X Post by ${userName || username} (@${handle || username}) — ${originalUrl}]`;
296
-
297
- // Check for quoted tweet
298
- const quoted = data.quoted_tweet as Record<string, unknown> | undefined;
299
- let quotedText = "";
300
- if (quoted?.text) {
301
- const qUser = (quoted.user as Record<string, unknown>)?.screen_name || "unknown";
302
- quotedText = `\n\n[Quoted @${qUser}]: ${quoted.text as string}`;
303
- }
304
-
305
- // Check for media descriptions
306
- const mediaDetails = data.mediaDetails as Array<Record<string, unknown>> | undefined;
307
- let mediaText = "";
308
- if (mediaDetails && mediaDetails.length > 0) {
309
- const types = mediaDetails.map((m) => m.type as string).filter(Boolean);
310
- mediaText = `\n[Media: ${types.join(", ")}]`;
311
- }
312
-
313
- return `${header}\n\n${text}${quotedText}${mediaText}`;
314
- }
315
- }
316
- } catch {
317
- // Syndication failed — fall through to web page fetch
318
- }
319
-
320
- // Fallback: fetch the page directly (may get limited HTML)
321
- return fetchWebPageText(originalUrl);
322
- }
323
-
324
- // ---------------------------------------------------------------------------
325
- // General Web Page
326
- // ---------------------------------------------------------------------------
327
-
328
- async function fetchWebPageText(url: string): Promise<string | null> {
329
- try {
330
- const res = await safeFetch(url, {
331
- headers: { "User-Agent": USER_AGENT },
332
- });
333
-
334
- const contentType = res.headers.get("content-type") || "";
335
- if (
336
- !contentType.includes("text/html") &&
337
- !contentType.includes("text/plain") &&
338
- !contentType.includes("application/json")
339
- ) {
340
- return null; // Skip binary content (images, videos, PDFs, etc.)
341
- }
342
-
343
- // Cap response body to 512KB to prevent memory exhaustion from large pages
344
- const MAX_BODY_BYTES = 512 * 1024;
345
- const reader = res.body?.getReader();
346
- if (!reader) return null;
347
- const chunks: Uint8Array[] = [];
348
- let totalBytes = 0;
349
- while (totalBytes < MAX_BODY_BYTES) {
350
- const { done, value } = await reader.read();
351
- if (done) break;
352
- chunks.push(value);
353
- totalBytes += value.length;
354
- }
355
- reader.cancel();
356
- const raw = new TextDecoder().decode(Buffer.concat(chunks));
357
-
358
- // JSON responses — return formatted
359
- if (contentType.includes("application/json")) {
360
- return `[API Response: ${url}]\n\n${raw.slice(0, MAX_WEBPAGE_CHARS)}`;
361
- }
362
-
363
- // Extract title
364
- const titleMatch = raw.match(/<title[^>]*>([\s\S]*?)<\/title>/i);
365
- const title = titleMatch ? decodeHtmlEntities(titleMatch[1]).trim() : "";
366
-
367
- // Strip non-content elements, then all tags
368
- const text = raw
369
- .replace(/<script[\s\S]*?<\/script>/gi, "")
370
- .replace(/<style[\s\S]*?<\/style>/gi, "")
371
- .replace(/<nav[\s\S]*?<\/nav>/gi, "")
372
- .replace(/<footer[\s\S]*?<\/footer>/gi, "")
373
- .replace(/<noscript[\s\S]*?<\/noscript>/gi, "")
374
- .replace(/<svg[\s\S]*?<\/svg>/gi, "")
375
- .replace(/<[^>]+>/g, " ")
376
- .replace(/\s+/g, " ")
377
- .trim();
378
-
379
- const cleaned = decodeHtmlEntities(text);
380
-
381
- if (cleaned.length < 100) return null;
382
-
383
- const header = title ? `[Web Page: "${title}" — ${url}]\n\n` : `[Web Page: ${url}]\n\n`;
384
-
385
- return header + cleaned.slice(0, MAX_WEBPAGE_CHARS);
386
- } catch {
387
- return null;
388
- }
389
- }
390
-
391
- // ---------------------------------------------------------------------------
392
- // Helpers
393
- // ---------------------------------------------------------------------------
394
-
395
- function decodeHtmlEntities(text: string): string {
396
- return text
397
- .replace(/&amp;/g, "&")
398
- .replace(/&lt;/g, "<")
399
- .replace(/&gt;/g, ">")
400
- .replace(/&#39;/g, "'")
401
- .replace(/&apos;/g, "'")
402
- .replace(/&quot;/g, '"')
403
- .replace(/&nbsp;/g, " ")
404
- .replace(/&#(\d+);/g, (_, code) => String.fromCharCode(Number(code)))
405
- .replace(/&#x([0-9a-fA-F]+);/g, (_, hex) => String.fromCharCode(parseInt(hex, 16)));
406
- }