refactor of data-service
This commit is contained in:
parent
6fb98c69f2
commit
09c97df1a8
49 changed files with 2394 additions and 112 deletions
24
libs/browser/package.json
Normal file
24
libs/browser/package.json
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
{
|
||||
"name": "@stock-bot/browser",
|
||||
"version": "1.0.0",
|
||||
"description": "High-performance browser automation library with proxy support",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"test": "bun test",
|
||||
"dev": "tsc --watch"
|
||||
},
|
||||
"dependencies": {
|
||||
"playwright": "^1.53.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.0.0",
|
||||
"typescript": "^5.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@stock-bot/logger": "workspace:*",
|
||||
"@stock-bot/http": "workspace:*"
|
||||
}
|
||||
}
|
||||
0
libs/browser/src/browser-pool.ts
Normal file
0
libs/browser/src/browser-pool.ts
Normal file
361
libs/browser/src/browser.ts
Normal file
361
libs/browser/src/browser.ts
Normal file
|
|
@ -0,0 +1,361 @@
|
|||
import { BrowserContext, chromium, Page, Browser as PlaywrightBrowser } from 'playwright';
|
||||
import { getLogger } from '@stock-bot/logger';
|
||||
import type { BrowserOptions, NetworkEvent, NetworkEventHandler } from './types';
|
||||
|
||||
class BrowserSingleton {
|
||||
private browser?: PlaywrightBrowser;
|
||||
private contexts: Map<string, BrowserContext> = new Map();
|
||||
private logger = getLogger('browser');
|
||||
private options: BrowserOptions;
|
||||
private initialized = false;
|
||||
|
||||
constructor() {
|
||||
this.options = {
|
||||
headless: true,
|
||||
timeout: 30000,
|
||||
blockResources: false,
|
||||
enableNetworkLogging: false,
|
||||
};
|
||||
}
|
||||
|
||||
async initialize(options: BrowserOptions = {}): Promise<void> {
|
||||
if (this.initialized) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Merge options
|
||||
this.options = {
|
||||
...this.options,
|
||||
...options,
|
||||
};
|
||||
|
||||
this.logger.info('Initializing browser...');
|
||||
|
||||
try {
|
||||
this.browser = await chromium.launch({
|
||||
headless: this.options.headless,
|
||||
timeout: this.options.timeout,
|
||||
args: [
|
||||
// Security and sandbox
|
||||
'--no-sandbox',
|
||||
// '--disable-setuid-sandbox',
|
||||
// '--disable-dev-shm-usage',
|
||||
// '--disable-web-security',
|
||||
// '--disable-features=VizDisplayCompositor',
|
||||
// '--disable-blink-features=AutomationControlled',
|
||||
|
||||
// // Performance optimizations
|
||||
// '--disable-gpu',
|
||||
// '--disable-gpu-sandbox',
|
||||
// '--disable-software-rasterizer',
|
||||
// '--disable-background-timer-throttling',
|
||||
// '--disable-renderer-backgrounding',
|
||||
// '--disable-backgrounding-occluded-windows',
|
||||
// '--disable-field-trial-config',
|
||||
// '--disable-back-forward-cache',
|
||||
// '--disable-hang-monitor',
|
||||
// '--disable-ipc-flooding-protection',
|
||||
|
||||
// // Extensions and plugins
|
||||
// '--disable-extensions',
|
||||
// '--disable-plugins',
|
||||
// '--disable-component-extensions-with-background-pages',
|
||||
// '--disable-component-update',
|
||||
// '--disable-plugins-discovery',
|
||||
// '--disable-bundled-ppapi-flash',
|
||||
|
||||
// // Features we don't need
|
||||
// '--disable-default-apps',
|
||||
// '--disable-sync',
|
||||
// '--disable-translate',
|
||||
// '--disable-client-side-phishing-detection',
|
||||
// '--disable-domain-reliability',
|
||||
// '--disable-features=TranslateUI',
|
||||
// '--disable-features=Translate',
|
||||
// '--disable-breakpad',
|
||||
// '--disable-preconnect',
|
||||
// '--disable-print-preview',
|
||||
// '--disable-password-generation',
|
||||
// '--disable-password-manager-reauthentication',
|
||||
// '--disable-save-password-bubble',
|
||||
// '--disable-single-click-autofill',
|
||||
// '--disable-autofill',
|
||||
// '--disable-autofill-keyboard-accessory-view',
|
||||
// '--disable-full-form-autofill-ios',
|
||||
|
||||
// // Audio/Video/Media
|
||||
// '--mute-audio',
|
||||
// '--disable-audio-output',
|
||||
// '--autoplay-policy=user-gesture-required',
|
||||
// '--disable-background-media-playback',
|
||||
|
||||
// // Networking
|
||||
// '--disable-background-networking',
|
||||
// '--disable-sync',
|
||||
// '--aggressive-cache-discard',
|
||||
// '--disable-default-apps',
|
||||
|
||||
// // UI/UX optimizations
|
||||
// '--no-first-run',
|
||||
// '--disable-infobars',
|
||||
// '--disable-notifications',
|
||||
// '--disable-desktop-notifications',
|
||||
// '--disable-prompt-on-repost',
|
||||
// '--disable-logging',
|
||||
// '--disable-file-system',
|
||||
// '--hide-scrollbars',
|
||||
|
||||
// // Memory optimizations
|
||||
// '--memory-pressure-off',
|
||||
// '--max_old_space_size=4096',
|
||||
// '--js-flags="--max-old-space-size=4096"',
|
||||
// '--media-cache-size=1',
|
||||
// '--disk-cache-size=1',
|
||||
|
||||
// // Process management
|
||||
// '--use-mock-keychain',
|
||||
// '--password-store=basic',
|
||||
// '--enable-automation',
|
||||
// '--no-pings',
|
||||
// '--no-service-autorun',
|
||||
// '--metrics-recording-only',
|
||||
// '--safebrowsing-disable-auto-update',
|
||||
|
||||
// // Disable unnecessary features for headless mode
|
||||
// '--disable-speech-api',
|
||||
// '--disable-gesture-typing',
|
||||
// '--disable-voice-input',
|
||||
// '--disable-wake-on-wifi',
|
||||
// '--disable-webgl',
|
||||
// '--disable-webgl2',
|
||||
// '--disable-3d-apis',
|
||||
// '--disable-accelerated-2d-canvas',
|
||||
// '--disable-accelerated-jpeg-decoding',
|
||||
// '--disable-accelerated-mjpeg-decode',
|
||||
// '--disable-accelerated-video-decode',
|
||||
// '--disable-canvas-aa',
|
||||
// '--disable-2d-canvas-clip-aa',
|
||||
// '--disable-gl-drawing-for-tests',
|
||||
],
|
||||
});
|
||||
|
||||
this.initialized = true;
|
||||
this.logger.info('Browser initialized successfully');
|
||||
} catch (error) {
|
||||
this.logger.error('Failed to initialize browser', { error });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async createPageWithProxy(
|
||||
url: string,
|
||||
proxy?: string
|
||||
): Promise<{
|
||||
page: Page & {
|
||||
onNetworkEvent: (handler: NetworkEventHandler) => void;
|
||||
offNetworkEvent: (handler: NetworkEventHandler) => void;
|
||||
clearNetworkListeners: () => void;
|
||||
};
|
||||
contextId: string;
|
||||
}> {
|
||||
if (!this.browser) {
|
||||
throw new Error('Browser not initialized. Call Browser.initialize() first.');
|
||||
}
|
||||
|
||||
const contextId = `ctx-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
||||
|
||||
const contextOptions: Record<string, unknown> = {
|
||||
ignoreHTTPSErrors: true,
|
||||
bypassCSP: true,
|
||||
};
|
||||
|
||||
if (proxy) {
|
||||
const [protocol, rest] = proxy.split('://');
|
||||
const [auth, hostPort] = rest.includes('@') ? rest.split('@') : [null, rest];
|
||||
const [host, port] = hostPort.split(':');
|
||||
|
||||
contextOptions.proxy = {
|
||||
server: `${protocol}://${host}:${port}`,
|
||||
username: auth?.split(':')[0] || '',
|
||||
password: auth?.split(':')[1] || '',
|
||||
};
|
||||
}
|
||||
|
||||
const context = await this.browser.newContext(contextOptions);
|
||||
|
||||
// Block resources for performance
|
||||
if (this.options.blockResources) {
|
||||
await context.route('**/*.{png,jpg,jpeg,gif,svg,ico,woff,woff2,ttf,css}', route => {
|
||||
route.abort();
|
||||
});
|
||||
}
|
||||
|
||||
this.contexts.set(contextId, context);
|
||||
|
||||
const page = await context.newPage();
|
||||
page.setDefaultTimeout(this.options.timeout || 30000);
|
||||
page.setDefaultNavigationTimeout(this.options.timeout || 30000);
|
||||
|
||||
// Create network event handlers for this page
|
||||
const networkEventHandlers: Set<NetworkEventHandler> = new Set();
|
||||
|
||||
// Add network monitoring methods to the page
|
||||
const enhancedPage = page as Page & {
|
||||
onNetworkEvent: (handler: NetworkEventHandler) => void;
|
||||
offNetworkEvent: (handler: NetworkEventHandler) => void;
|
||||
clearNetworkListeners: () => void;
|
||||
};
|
||||
|
||||
enhancedPage.onNetworkEvent = (handler: NetworkEventHandler) => {
|
||||
networkEventHandlers.add(handler);
|
||||
|
||||
// Set up network monitoring on first handler
|
||||
if (networkEventHandlers.size === 1) {
|
||||
this.setupNetworkMonitoring(page, networkEventHandlers);
|
||||
}
|
||||
};
|
||||
|
||||
enhancedPage.offNetworkEvent = (handler: NetworkEventHandler) => {
|
||||
networkEventHandlers.delete(handler);
|
||||
};
|
||||
|
||||
enhancedPage.clearNetworkListeners = () => {
|
||||
networkEventHandlers.clear();
|
||||
};
|
||||
|
||||
if (url) {
|
||||
await page.goto(url, {
|
||||
waitUntil: 'domcontentloaded',
|
||||
timeout: this.options.timeout,
|
||||
});
|
||||
}
|
||||
|
||||
return { page: enhancedPage, contextId };
|
||||
}
|
||||
|
||||
private setupNetworkMonitoring(page: Page, handlers: Set<NetworkEventHandler>): void {
|
||||
// Listen to requests
|
||||
page.on('request', async request => {
|
||||
const event: NetworkEvent = {
|
||||
url: request.url(),
|
||||
method: request.method(),
|
||||
type: 'request',
|
||||
timestamp: Date.now(),
|
||||
headers: request.headers(),
|
||||
};
|
||||
|
||||
// Capture request data for POST/PUT/PATCH requests
|
||||
if (['POST', 'PUT', 'PATCH'].includes(request.method())) {
|
||||
try {
|
||||
const postData = request.postData();
|
||||
if (postData) {
|
||||
event.requestData = postData;
|
||||
}
|
||||
} catch {
|
||||
// Some requests might not have accessible post data
|
||||
}
|
||||
}
|
||||
|
||||
this.emitNetworkEvent(event, handlers);
|
||||
});
|
||||
|
||||
// Listen to responses
|
||||
page.on('response', async response => {
|
||||
const event: NetworkEvent = {
|
||||
url: response.url(),
|
||||
method: response.request().method(),
|
||||
status: response.status(),
|
||||
type: 'response',
|
||||
timestamp: Date.now(),
|
||||
headers: response.headers(),
|
||||
};
|
||||
|
||||
// Capture response data for GET/POST requests with JSON content
|
||||
const contentType = response.headers()['content-type'] || '';
|
||||
if (contentType.includes('application/json') || contentType.includes('text/')) {
|
||||
try {
|
||||
const responseData = await response.text();
|
||||
event.responseData = responseData;
|
||||
} catch {
|
||||
// Response might be too large or not accessible
|
||||
}
|
||||
}
|
||||
|
||||
this.emitNetworkEvent(event, handlers);
|
||||
});
|
||||
|
||||
// Listen to failed requests
|
||||
page.on('requestfailed', request => {
|
||||
const event: NetworkEvent = {
|
||||
url: request.url(),
|
||||
method: request.method(),
|
||||
type: 'failed',
|
||||
timestamp: Date.now(),
|
||||
headers: request.headers(),
|
||||
};
|
||||
|
||||
// Try to capture request data for failed requests too
|
||||
if (['POST', 'PUT', 'PATCH'].includes(request.method())) {
|
||||
try {
|
||||
const postData = request.postData();
|
||||
if (postData) {
|
||||
event.requestData = postData;
|
||||
}
|
||||
} catch {
|
||||
// Ignore errors when accessing post data
|
||||
}
|
||||
}
|
||||
|
||||
this.emitNetworkEvent(event, handlers);
|
||||
});
|
||||
}
|
||||
|
||||
private emitNetworkEvent(event: NetworkEvent, handlers: Set<NetworkEventHandler>): void {
|
||||
for (const handler of handlers) {
|
||||
try {
|
||||
handler(event);
|
||||
} catch (error) {
|
||||
this.logger.error('Network event handler error', { error });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async evaluate<T>(page: Page, fn: () => T): Promise<T> {
|
||||
return page.evaluate(fn);
|
||||
}
|
||||
|
||||
async closeContext(contextId: string): Promise<void> {
|
||||
const context = this.contexts.get(contextId);
|
||||
if (context) {
|
||||
await context.close();
|
||||
this.contexts.delete(contextId);
|
||||
}
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
// Close all contexts
|
||||
for (const [, context] of this.contexts) {
|
||||
await context.close();
|
||||
}
|
||||
this.contexts.clear();
|
||||
|
||||
// Close browser
|
||||
if (this.browser) {
|
||||
await this.browser.close();
|
||||
this.browser = undefined;
|
||||
}
|
||||
|
||||
this.initialized = false;
|
||||
this.logger.info('Browser closed');
|
||||
}
|
||||
|
||||
get isInitialized(): boolean {
|
||||
return this.initialized;
|
||||
}
|
||||
}
|
||||
|
||||
// Export singleton instance
|
||||
export const Browser = new BrowserSingleton();
|
||||
|
||||
// Also export the class for typing if needed
|
||||
export { BrowserSingleton as BrowserClass };
|
||||
0
libs/browser/src/fast-browser.ts
Normal file
0
libs/browser/src/fast-browser.ts
Normal file
3
libs/browser/src/index.ts
Normal file
3
libs/browser/src/index.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
export { Browser } from './browser';
|
||||
export { BrowserTabManager } from './tab-manager';
|
||||
export type { BrowserOptions, ScrapingResult } from './types';
|
||||
103
libs/browser/src/tab-manager.ts
Normal file
103
libs/browser/src/tab-manager.ts
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
import { Page } from 'playwright';
|
||||
import { getLogger } from '@stock-bot/logger';
|
||||
import { Browser } from './browser';
|
||||
import type { ScrapingResult } from './types';
|
||||
|
||||
interface TabInfo {
|
||||
page: Page;
|
||||
contextId: string;
|
||||
}
|
||||
|
||||
export class BrowserTabManager {
|
||||
private tabs: Map<string, TabInfo> = new Map();
|
||||
private logger = getLogger('browser-tab-manager');
|
||||
|
||||
async createTab(url?: string): Promise<{ page: Page; tabId: string }> {
|
||||
const tabId = `tab-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
||||
const { page, contextId } = await Browser.createPageWithProxy(url || 'about:blank');
|
||||
|
||||
this.tabs.set(tabId, { page, contextId });
|
||||
this.logger.debug('Tab created', { tabId, url });
|
||||
|
||||
return { page, tabId };
|
||||
}
|
||||
|
||||
async createTabWithProxy(
|
||||
url: string,
|
||||
proxy: string
|
||||
): Promise<{ page: Page; tabId: string; contextId: string }> {
|
||||
const tabId = `tab-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
||||
const { page, contextId } = await Browser.createPageWithProxy(url, proxy);
|
||||
|
||||
this.tabs.set(tabId, { page, contextId });
|
||||
this.logger.debug('Tab with proxy created', { tabId, url, proxy });
|
||||
|
||||
return { page, tabId, contextId };
|
||||
}
|
||||
|
||||
async scrapeUrlsWithProxies<T>(
|
||||
urlProxyPairs: Array<{ url: string; proxy: string }>,
|
||||
extractor: (page: Page) => Promise<T>,
|
||||
options: { concurrency?: number } = {}
|
||||
): Promise<ScrapingResult<T>[]> {
|
||||
const { concurrency = 3 } = options;
|
||||
const results: ScrapingResult<T>[] = [];
|
||||
|
||||
for (let i = 0; i < urlProxyPairs.length; i += concurrency) {
|
||||
const batch = urlProxyPairs.slice(i, i + concurrency);
|
||||
|
||||
const batchPromises = batch.map(async ({ url, proxy }) => {
|
||||
let tabId: string | undefined;
|
||||
|
||||
try {
|
||||
const result = await this.createTabWithProxy(url, proxy);
|
||||
tabId = result.tabId;
|
||||
|
||||
const data = await extractor(result.page);
|
||||
|
||||
return {
|
||||
data,
|
||||
url,
|
||||
success: true,
|
||||
} as ScrapingResult<T>;
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
|
||||
return {
|
||||
data: null as T,
|
||||
url,
|
||||
success: false,
|
||||
error: errorMessage,
|
||||
} as ScrapingResult<T>;
|
||||
} finally {
|
||||
if (tabId) {
|
||||
await this.closeTab(tabId);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const batchResults = await Promise.all(batchPromises);
|
||||
results.push(...batchResults);
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
async closeTab(tabId: string): Promise<void> {
|
||||
const tab = this.tabs.get(tabId);
|
||||
if (tab) {
|
||||
await tab.page.close();
|
||||
await Browser.closeContext(tab.contextId);
|
||||
this.tabs.delete(tabId);
|
||||
this.logger.debug('Tab closed', { tabId });
|
||||
}
|
||||
}
|
||||
|
||||
getTabCount(): number {
|
||||
return this.tabs.size;
|
||||
}
|
||||
|
||||
getAllTabIds(): string[] {
|
||||
return Array.from(this.tabs.keys());
|
||||
}
|
||||
}
|
||||
30
libs/browser/src/types.ts
Normal file
30
libs/browser/src/types.ts
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
export interface BrowserOptions {
|
||||
proxy?: string;
|
||||
headless?: boolean;
|
||||
timeout?: number;
|
||||
blockResources?: boolean;
|
||||
enableNetworkLogging?: boolean;
|
||||
}
|
||||
|
||||
// Keep the old name for backward compatibility
|
||||
export type FastBrowserOptions = BrowserOptions;
|
||||
|
||||
export interface ScrapingResult<T = unknown> {
|
||||
data: T;
|
||||
url: string;
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface NetworkEvent {
|
||||
url: string;
|
||||
method: string;
|
||||
status?: number;
|
||||
type: 'request' | 'response' | 'failed';
|
||||
timestamp: number;
|
||||
requestData?: string;
|
||||
responseData?: string;
|
||||
headers?: Record<string, string>;
|
||||
}
|
||||
|
||||
export type NetworkEventHandler = (event: NetworkEvent) => void;
|
||||
0
libs/browser/src/utils.ts
Normal file
0
libs/browser/src/utils.ts
Normal file
10
libs/browser/tsconfig.json
Normal file
10
libs/browser/tsconfig.json
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
{
|
||||
"extends": "../../tsconfig.lib.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src"
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist", "**/*.test.ts", "**/*.spec.ts"],
|
||||
"references": [{ "path": "../../libs/logger" }]
|
||||
}
|
||||
13
libs/cache/src/redis-cache.ts
vendored
13
libs/cache/src/redis-cache.ts
vendored
|
|
@ -289,6 +289,19 @@ export class RedisCache implements CacheProvider {
|
|||
);
|
||||
}
|
||||
|
||||
async keys(pattern: string): Promise<string[]> {
|
||||
return this.safeExecute(
|
||||
async () => {
|
||||
const fullPattern = `${this.keyPrefix}${pattern}`;
|
||||
const keys = await this.redis.keys(fullPattern);
|
||||
// Remove the prefix from returned keys to match the interface expectation
|
||||
return keys.map(key => key.replace(this.keyPrefix, ''));
|
||||
},
|
||||
[],
|
||||
'keys'
|
||||
);
|
||||
}
|
||||
|
||||
async health(): Promise<boolean> {
|
||||
try {
|
||||
const pong = await this.redis.ping();
|
||||
|
|
|
|||
1
libs/cache/src/types.ts
vendored
1
libs/cache/src/types.ts
vendored
|
|
@ -16,6 +16,7 @@ export interface CacheProvider {
|
|||
del(key: string): Promise<void>;
|
||||
exists(key: string): Promise<boolean>;
|
||||
clear(): Promise<void>;
|
||||
keys(pattern: string): Promise<string[]>;
|
||||
getStats(): CacheStats;
|
||||
health(): Promise<boolean>;
|
||||
|
||||
|
|
|
|||
|
|
@ -20,15 +20,17 @@
|
|||
"axios": "^1.9.0",
|
||||
"http-proxy-agent": "^7.0.2",
|
||||
"https-proxy-agent": "^7.0.6",
|
||||
"socks-proxy-agent": "^8.0.5"
|
||||
"socks-proxy-agent": "^8.0.5",
|
||||
"user-agents": "^1.1.567"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.11.0",
|
||||
"typescript": "^5.3.0",
|
||||
"eslint": "^8.56.0",
|
||||
"@types/user-agents": "^1.0.4",
|
||||
"@typescript-eslint/eslint-plugin": "^6.19.0",
|
||||
"@typescript-eslint/parser": "^6.19.0",
|
||||
"bun-types": "^1.2.15"
|
||||
"bun-types": "^1.2.15",
|
||||
"eslint": "^8.56.0",
|
||||
"typescript": "^5.3.0"
|
||||
},
|
||||
"exports": {
|
||||
".": {
|
||||
|
|
|
|||
|
|
@ -11,15 +11,17 @@ export class AxiosAdapter implements RequestAdapter {
|
|||
canHandle(config: RequestConfig): boolean {
|
||||
// Axios handles SOCKS proxies
|
||||
return Boolean(
|
||||
config.proxy && (config.proxy.protocol === 'socks4' || config.proxy.protocol === 'socks5')
|
||||
config.proxy &&
|
||||
typeof config.proxy !== 'string' &&
|
||||
(config.proxy.protocol === 'socks4' || config.proxy.protocol === 'socks5')
|
||||
);
|
||||
}
|
||||
|
||||
async request<T = any>(config: RequestConfig, signal: AbortSignal): Promise<HttpResponse<T>> {
|
||||
const { url, method = 'GET', headers, data, proxy } = config;
|
||||
|
||||
if (!proxy) {
|
||||
throw new Error('Axios adapter requires proxy configuration');
|
||||
if (!proxy || typeof proxy === 'string') {
|
||||
throw new Error('Axios adapter requires ProxyInfo configuration');
|
||||
}
|
||||
|
||||
// Create proxy configuration using ProxyManager
|
||||
|
|
|
|||
|
|
@ -9,6 +9,9 @@ import type { RequestAdapter } from './types';
|
|||
export class FetchAdapter implements RequestAdapter {
|
||||
canHandle(config: RequestConfig): boolean {
|
||||
// Fetch handles non-proxy requests and HTTP/HTTPS proxies
|
||||
if (typeof config.proxy === 'string') {
|
||||
return config.proxy.startsWith('http');
|
||||
}
|
||||
return !config.proxy || config.proxy.protocol === 'http' || config.proxy.protocol === 'https';
|
||||
}
|
||||
|
||||
|
|
@ -31,7 +34,11 @@ export class FetchAdapter implements RequestAdapter {
|
|||
}
|
||||
|
||||
// Add proxy if needed (using Bun's built-in proxy support)
|
||||
if (proxy) {
|
||||
if (typeof proxy === 'string') {
|
||||
// If proxy is a URL string, use it directly
|
||||
(fetchOptions as any).proxy = proxy;
|
||||
} else if (proxy) {
|
||||
// If proxy is a ProxyInfo object, create a proxy URL
|
||||
(fetchOptions as any).proxy = ProxyManager.createProxyUrl(proxy);
|
||||
}
|
||||
const response = await fetch(url, fetchOptions);
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import type { Logger } from '@stock-bot/logger';
|
|||
import { AdapterFactory } from './adapters/index';
|
||||
import type { HttpClientConfig, HttpResponse, RequestConfig } from './types';
|
||||
import { HttpError } from './types';
|
||||
import { getRandomUserAgent } from './user-agent';
|
||||
|
||||
export class HttpClient {
|
||||
private readonly config: HttpClientConfig;
|
||||
|
|
@ -165,9 +166,17 @@ export class HttpClient {
|
|||
* Merge configs with defaults
|
||||
*/
|
||||
private mergeConfig(config: RequestConfig): RequestConfig {
|
||||
// Merge headers with automatic User-Agent assignment
|
||||
const mergedHeaders = { ...this.config.headers, ...config.headers };
|
||||
|
||||
// Add random User-Agent if not specified
|
||||
if (!mergedHeaders['User-Agent'] && !mergedHeaders['user-agent']) {
|
||||
mergedHeaders['User-Agent'] = getRandomUserAgent();
|
||||
}
|
||||
|
||||
return {
|
||||
...config,
|
||||
headers: { ...this.config.headers, ...config.headers },
|
||||
headers: mergedHeaders,
|
||||
timeout: config.timeout ?? this.config.timeout,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,9 @@
|
|||
// Re-export all types and classes
|
||||
export * from './types';
|
||||
export * from './adapters/index';
|
||||
export * from './client';
|
||||
export * from './proxy-manager';
|
||||
export * from './adapters/index';
|
||||
export * from './types';
|
||||
export * from './user-agent';
|
||||
|
||||
// Default export
|
||||
export { HttpClient as default } from './client';
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@ export interface RequestConfig {
|
|||
headers?: Record<string, string>;
|
||||
data?: any; // Changed from 'body' to 'data' for consistency
|
||||
timeout?: number;
|
||||
proxy?: ProxyInfo;
|
||||
proxy?: ProxyInfo | string; // Proxy can be a ProxyInfo object or a URL string
|
||||
}
|
||||
|
||||
export interface HttpResponse<T = any> {
|
||||
|
|
|
|||
6
libs/http/src/user-agent.ts
Normal file
6
libs/http/src/user-agent.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
import UserAgent from 'user-agents';
|
||||
|
||||
export function getRandomUserAgent(): string {
|
||||
const userAgent = new UserAgent();
|
||||
return userAgent.toString();
|
||||
}
|
||||
18
libs/proxy/package.json
Normal file
18
libs/proxy/package.json
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
{
|
||||
"name": "@stock-bot/proxy",
|
||||
"version": "1.0.0",
|
||||
"description": "Simple proxy management library",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"test": "bun test",
|
||||
"dev": "tsc --watch"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.0.0",
|
||||
"typescript": "^5.0.0"
|
||||
},
|
||||
"peerDependencies": {}
|
||||
}
|
||||
97
libs/proxy/src/index.ts
Normal file
97
libs/proxy/src/index.ts
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
// Simple proxy list manager
|
||||
let proxies: string[] = [];
|
||||
let currentIndex = 0;
|
||||
|
||||
const DEFAULT_PROXY_URL =
|
||||
'https://api.proxyscrape.com/v2/?request=getproxies&protocol=http&timeout=10000&country=all&ssl=all&anonymity=all';
|
||||
|
||||
/**
|
||||
* Fetch proxy list from URL and store in module
|
||||
*/
|
||||
export async function refreshProxies(fetchUrl: string = DEFAULT_PROXY_URL): Promise<string[]> {
|
||||
try {
|
||||
const response = await fetch(fetchUrl);
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const data = await response.text();
|
||||
const newProxies = data
|
||||
.trim()
|
||||
.split('\n')
|
||||
.map(line => line.trim())
|
||||
.filter(line => line && line.includes(':'))
|
||||
.map(line => {
|
||||
// Convert host:port to http://host:port format
|
||||
return line.startsWith('http') ? line : `http://${line}`;
|
||||
});
|
||||
|
||||
proxies = newProxies;
|
||||
currentIndex = 0;
|
||||
|
||||
return proxies;
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to fetch proxies: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get next proxy URL in round-robin fashion
|
||||
*/
|
||||
export function getProxyURL(): string | null {
|
||||
if (proxies.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const proxy = proxies[currentIndex];
|
||||
currentIndex = (currentIndex + 1) % proxies.length;
|
||||
|
||||
return proxy;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get multiple proxy URLs
|
||||
*/
|
||||
export function getProxyURLs(count: number): string[] {
|
||||
const urls: string[] = [];
|
||||
for (let i = 0; i < count; i++) {
|
||||
const url = getProxyURL();
|
||||
if (url) {
|
||||
urls.push(url);
|
||||
}
|
||||
}
|
||||
return urls;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get random proxy URL
|
||||
*/
|
||||
export function getRandomProxyURL(): string | null {
|
||||
if (proxies.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const randomIndex = Math.floor(Math.random() * proxies.length);
|
||||
return proxies[randomIndex];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current proxy count
|
||||
*/
|
||||
export function getProxyCount(): number {
|
||||
return proxies.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all proxies
|
||||
*/
|
||||
export function getAllProxies(): string[] {
|
||||
return [...proxies];
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize proxy manager with initial fetch
|
||||
*/
|
||||
export async function initializeProxies(fetchUrl?: string): Promise<void> {
|
||||
await refreshProxies(fetchUrl);
|
||||
}
|
||||
0
libs/proxy/src/proxy-manager.ts
Normal file
0
libs/proxy/src/proxy-manager.ts
Normal file
22
libs/proxy/src/types.ts
Normal file
22
libs/proxy/src/types.ts
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
export interface ProxyInfo {
|
||||
host: string;
|
||||
port: number;
|
||||
protocol: 'http' | 'https' | 'socks4' | 'socks5';
|
||||
username?: string;
|
||||
password?: string;
|
||||
country?: string;
|
||||
isActive?: boolean;
|
||||
}
|
||||
|
||||
export interface ProxyManagerOptions {
|
||||
fetchUrl?: string;
|
||||
refreshIntervalMs?: number;
|
||||
maxRetries?: number;
|
||||
timeout?: number;
|
||||
}
|
||||
|
||||
export interface ProxyResponse {
|
||||
proxies: ProxyInfo[];
|
||||
totalCount: number;
|
||||
activeCount: number;
|
||||
}
|
||||
10
libs/proxy/tsconfig.json
Normal file
10
libs/proxy/tsconfig.json
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
{
|
||||
"extends": "../../tsconfig.lib.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"skipLibCheck": true
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist", "**/*.test.ts", "**/*.spec.ts"]
|
||||
}
|
||||
7
libs/utils/src/common.ts
Normal file
7
libs/utils/src/common.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
export function createProxyUrl(proxy: any): string {
|
||||
const { protocol, host, port, username, password } = proxy;
|
||||
if (username && password) {
|
||||
return `${protocol}://${encodeURIComponent(username)}:${encodeURIComponent(password)}@${host}:${port}`;
|
||||
}
|
||||
return `${protocol}://${host}:${port}`;
|
||||
}
|
||||
|
|
@ -1,2 +1,3 @@
|
|||
export * from './dateUtils';
|
||||
export * from './calculations/index';
|
||||
export * from './common';
|
||||
export * from './dateUtils';
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue