Skip to content

Commit 491737f

Browse files
authored
Kamino bank listings (#43)
* Kamino bank listing scripts and configs * Init obligations for SOL Jito, SOL Marinade, USDS Maple
1 parent 1991966 commit 491737f

20 files changed

Lines changed: 3920 additions & 23 deletions

lib/web-scraper.ts

Lines changed: 231 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,231 @@
1+
/**
2+
* Web Scraper Utility
3+
*
4+
* Uses puppeteer-core to render JavaScript-heavy pages (like Switchboard, Kamino)
5+
* and extract content that would otherwise require a browser.
6+
*
7+
* Requires Chrome/Chromium to be installed on the system.
8+
* Install with: apt install chromium-browser (Linux) or brew install chromium (macOS)
9+
*/
10+
11+
import puppeteer, { Browser, Page } from "puppeteer-core";
12+
import { execSync } from "child_process";
13+
14+
/**
15+
* Common Chrome/Chromium executable paths
16+
*/
17+
const CHROME_PATHS = [
18+
// Linux
19+
"/usr/bin/chromium",
20+
"/usr/bin/chromium-browser",
21+
"/usr/bin/google-chrome",
22+
"/usr/bin/google-chrome-stable",
23+
// macOS
24+
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
25+
"/Applications/Chromium.app/Contents/MacOS/Chromium",
26+
// Windows (WSL)
27+
"/mnt/c/Program Files/Google/Chrome/Application/chrome.exe",
28+
"/mnt/c/Program Files (x86)/Google/Chrome/Application/chrome.exe",
29+
];
30+
31+
/**
32+
* Find Chrome/Chromium executable path
33+
*/
34+
export function findChromePath(): string | null {
35+
// Try which command first
36+
try {
37+
const result = execSync("which chromium chromium-browser google-chrome google-chrome-stable 2>/dev/null", {
38+
encoding: "utf-8",
39+
}).trim();
40+
if (result) {
41+
return result.split("\n")[0];
42+
}
43+
} catch {
44+
// Ignore errors from which command
45+
}
46+
47+
// Check common paths
48+
for (const path of CHROME_PATHS) {
49+
try {
50+
execSync(`test -x "${path}"`, { encoding: "utf-8" });
51+
return path;
52+
} catch {
53+
// Path doesn't exist or isn't executable
54+
}
55+
}
56+
57+
return null;
58+
}
59+
60+
export interface ScrapeOptions {
61+
/** Wait for this selector before extracting content */
62+
waitForSelector?: string;
63+
/** Timeout in milliseconds (default: 30000) */
64+
timeout?: number;
65+
/** Extract specific element text instead of full page */
66+
extractSelector?: string;
67+
/** Return raw HTML instead of text */
68+
returnHtml?: boolean;
69+
}
70+
71+
export interface ScrapeResult {
72+
success: boolean;
73+
content?: string;
74+
error?: string;
75+
url: string;
76+
}
77+
78+
let browserInstance: Browser | null = null;
79+
80+
/**
81+
* Get or create a browser instance
82+
*/
83+
async function getBrowser(): Promise<Browser> {
84+
if (browserInstance && browserInstance.connected) {
85+
return browserInstance;
86+
}
87+
88+
const chromePath = findChromePath();
89+
if (!chromePath) {
90+
throw new Error(
91+
"Chrome/Chromium not found. Install with:\n" +
92+
" Linux: apt install chromium-browser\n" +
93+
" macOS: brew install chromium"
94+
);
95+
}
96+
97+
browserInstance = await puppeteer.launch({
98+
executablePath: chromePath,
99+
headless: true,
100+
args: ["--no-sandbox", "--disable-setuid-sandbox", "--disable-dev-shm-usage"],
101+
});
102+
103+
return browserInstance;
104+
}
105+
106+
/**
107+
* Close the browser instance
108+
*/
109+
export async function closeBrowser(): Promise<void> {
110+
if (browserInstance) {
111+
await browserInstance.close();
112+
browserInstance = null;
113+
}
114+
}
115+
116+
/**
117+
* Scrape a JavaScript-rendered page
118+
*
119+
* @param url URL to scrape
120+
* @param options Scrape options
121+
* @returns Scraped content or error
122+
*/
123+
export async function scrapeRenderedPage(
124+
url: string,
125+
options: ScrapeOptions = {}
126+
): Promise<ScrapeResult> {
127+
const { waitForSelector, timeout = 30000, extractSelector, returnHtml = false } = options;
128+
129+
let page: Page | null = null;
130+
131+
try {
132+
const browser = await getBrowser();
133+
page = await browser.newPage();
134+
135+
// Set a reasonable viewport
136+
await page.setViewport({ width: 1280, height: 800 });
137+
138+
// Navigate to the page
139+
await page.goto(url, {
140+
waitUntil: "networkidle2",
141+
timeout,
142+
});
143+
144+
// Wait for specific selector if provided
145+
if (waitForSelector) {
146+
await page.waitForSelector(waitForSelector, { timeout });
147+
}
148+
149+
// Extract content
150+
let content: string;
151+
152+
if (extractSelector) {
153+
// Extract specific element
154+
const element = await page.$(extractSelector);
155+
if (!element) {
156+
return {
157+
success: false,
158+
error: `Selector "${extractSelector}" not found`,
159+
url,
160+
};
161+
}
162+
content = returnHtml
163+
? await page.evaluate((el) => el.outerHTML, element)
164+
: await page.evaluate((el) => el.textContent || "", element);
165+
} else {
166+
// Extract full page
167+
content = returnHtml
168+
? await page.content()
169+
: await page.evaluate(() => document.body.innerText);
170+
}
171+
172+
return {
173+
success: true,
174+
content: content.trim(),
175+
url,
176+
};
177+
} catch (error) {
178+
return {
179+
success: false,
180+
error: error instanceof Error ? error.message : String(error),
181+
url,
182+
};
183+
} finally {
184+
if (page) {
185+
await page.close();
186+
}
187+
}
188+
}
189+
190+
/**
191+
* Scrape Switchboard feed page and extract key information
192+
*/
193+
export async function scrapeSwitchboardFeed(feedAddress: string): Promise<{
194+
success: boolean;
195+
authority?: string;
196+
name?: string;
197+
queue?: string;
198+
value?: string;
199+
error?: string;
200+
}> {
201+
const url = `https://ondemand.switchboard.xyz/solana/mainnet/feed/${feedAddress}`;
202+
203+
const result = await scrapeRenderedPage(url, {
204+
waitForSelector: "body",
205+
timeout: 30000,
206+
});
207+
208+
if (!result.success || !result.content) {
209+
return {
210+
success: false,
211+
error: result.error || "Failed to scrape page",
212+
};
213+
}
214+
215+
const content = result.content;
216+
217+
// Parse the content to extract key fields
218+
// These patterns may need adjustment based on actual page structure
219+
const authorityMatch = content.match(/Authority[:\s]+([A-Za-z0-9]{32,44})/i);
220+
const nameMatch = content.match(/Name[:\s]+([^\n]+)/i);
221+
const queueMatch = content.match(/Queue[:\s]+([A-Za-z0-9]{32,44})/i);
222+
const valueMatch = content.match(/(?:Value|Result|Price)[:\s]+\$?([\d.,]+)/i);
223+
224+
return {
225+
success: true,
226+
authority: authorityMatch?.[1]?.trim(),
227+
name: nameMatch?.[1]?.trim(),
228+
queue: queueMatch?.[1]?.trim(),
229+
value: valueMatch?.[1]?.trim(),
230+
};
231+
}

0 commit comments

Comments
 (0)