- Add scheduler UI with store schedules, job queue, and global settings - Add store crawl orchestrator for intelligent crawl workflow - Add multi-category intelligence detection (product, specials, brands, metadata) - Add CrawlerLogger for structured JSON logging - Add migrations for scheduler tables and dispensary linking - Add dispensary → scheduler navigation link - Support production/sandbox crawler modes per provider 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
490 lines
14 KiB
TypeScript
Executable File
490 lines
14 KiB
TypeScript
Executable File
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:3010';
|
|
|
|
class ApiClient {
|
|
private baseUrl: string;
|
|
|
|
constructor(baseUrl: string) {
|
|
this.baseUrl = baseUrl;
|
|
}
|
|
|
|
private getHeaders(): HeadersInit {
|
|
const token = localStorage.getItem('token');
|
|
return {
|
|
'Content-Type': 'application/json',
|
|
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
|
};
|
|
}
|
|
|
|
private async request<T>(endpoint: string, options?: RequestInit): Promise<T> {
|
|
const url = `${this.baseUrl}${endpoint}`;
|
|
const response = await fetch(url, {
|
|
...options,
|
|
headers: {
|
|
...this.getHeaders(),
|
|
...options?.headers,
|
|
},
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const error = await response.json().catch(() => ({ error: 'Request failed' }));
|
|
throw new Error(error.error || `HTTP ${response.status}`);
|
|
}
|
|
|
|
return response.json();
|
|
}
|
|
|
|
// Auth
|
|
async login(email: string, password: string) {
|
|
return this.request<{ token: string; user: any }>('/api/auth/login', {
|
|
method: 'POST',
|
|
body: JSON.stringify({ email, password }),
|
|
});
|
|
}
|
|
|
|
async getMe() {
|
|
return this.request<{ user: any }>('/api/auth/me');
|
|
}
|
|
|
|
// Dashboard
|
|
async getDashboardStats() {
|
|
return this.request<any>('/api/dashboard/stats');
|
|
}
|
|
|
|
async getDashboardActivity() {
|
|
return this.request<any>('/api/dashboard/activity');
|
|
}
|
|
|
|
// Stores
|
|
async getStores() {
|
|
return this.request<{ stores: any[] }>('/api/stores');
|
|
}
|
|
|
|
async getStore(id: number) {
|
|
return this.request<any>(`/api/stores/${id}`);
|
|
}
|
|
|
|
async createStore(data: any) {
|
|
return this.request<any>('/api/stores', {
|
|
method: 'POST',
|
|
body: JSON.stringify(data),
|
|
});
|
|
}
|
|
|
|
async updateStore(id: number, data: any) {
|
|
return this.request<any>(`/api/stores/${id}`, {
|
|
method: 'PUT',
|
|
body: JSON.stringify(data),
|
|
});
|
|
}
|
|
|
|
async getDispensaries() {
|
|
return this.request<{ dispensaries: any[] }>('/api/dispensaries');
|
|
}
|
|
|
|
async getDispensary(slug: string) {
|
|
return this.request<any>(`/api/dispensaries/${slug}`);
|
|
}
|
|
|
|
async updateDispensary(id: number, data: any) {
|
|
return this.request<any>(`/api/dispensaries/${id}`, {
|
|
method: 'PUT',
|
|
body: JSON.stringify(data),
|
|
});
|
|
}
|
|
|
|
async deleteStore(id: number) {
|
|
return this.request<any>(`/api/stores/${id}`, {
|
|
method: 'DELETE',
|
|
});
|
|
}
|
|
|
|
async scrapeStore(id: number, parallel?: number, userAgent?: string) {
|
|
return this.request<any>(`/api/stores/${id}/scrape`, {
|
|
method: 'POST',
|
|
body: JSON.stringify({ parallel, userAgent }),
|
|
});
|
|
}
|
|
|
|
async downloadStoreImages(id: number) {
|
|
return this.request<any>(`/api/stores/${id}/download-images`, {
|
|
method: 'POST',
|
|
});
|
|
}
|
|
|
|
async discoverStoreCategories(id: number) {
|
|
return this.request<any>(`/api/stores/${id}/discover-categories`, {
|
|
method: 'POST',
|
|
});
|
|
}
|
|
|
|
async debugScrapeStore(id: number) {
|
|
return this.request<any>(`/api/stores/${id}/debug-scrape`, {
|
|
method: 'POST',
|
|
});
|
|
}
|
|
|
|
async getStoreBrands(id: number) {
|
|
return this.request<{ brands: string[] }>(`/api/stores/${id}/brands`);
|
|
}
|
|
|
|
async getStoreSpecials(id: number, date?: string) {
|
|
const params = date ? `?date=${date}` : '';
|
|
return this.request<{ specials: any[]; date: string }>(`/api/stores/${id}/specials${params}`);
|
|
}
|
|
|
|
// Categories
|
|
async getCategories(storeId?: number) {
|
|
const params = storeId ? `?store_id=${storeId}` : '';
|
|
return this.request<{ categories: any[] }>(`/api/categories${params}`);
|
|
}
|
|
|
|
async getCategoryTree(storeId: number) {
|
|
return this.request<any>(`/api/categories/tree?store_id=${storeId}`);
|
|
}
|
|
|
|
// Products
|
|
async getProducts(params?: any) {
|
|
const query = new URLSearchParams(params).toString();
|
|
return this.request<any>(`/api/products${query ? `?${query}` : ''}`);
|
|
}
|
|
|
|
async getProduct(id: number) {
|
|
return this.request<any>(`/api/products/${id}`);
|
|
}
|
|
|
|
// Campaigns
|
|
async getCampaigns() {
|
|
return this.request<{ campaigns: any[] }>('/api/campaigns');
|
|
}
|
|
|
|
async getCampaign(id: number) {
|
|
return this.request<any>(`/api/campaigns/${id}`);
|
|
}
|
|
|
|
async createCampaign(data: any) {
|
|
return this.request<any>('/api/campaigns', {
|
|
method: 'POST',
|
|
body: JSON.stringify(data),
|
|
});
|
|
}
|
|
|
|
async updateCampaign(id: number, data: any) {
|
|
return this.request<any>(`/api/campaigns/${id}`, {
|
|
method: 'PUT',
|
|
body: JSON.stringify(data),
|
|
});
|
|
}
|
|
|
|
async deleteCampaign(id: number) {
|
|
return this.request<any>(`/api/campaigns/${id}`, {
|
|
method: 'DELETE',
|
|
});
|
|
}
|
|
|
|
async addProductToCampaign(campaignId: number, productId: number, displayOrder?: number) {
|
|
return this.request<any>(`/api/campaigns/${campaignId}/products`, {
|
|
method: 'POST',
|
|
body: JSON.stringify({ product_id: productId, display_order: displayOrder }),
|
|
});
|
|
}
|
|
|
|
async removeProductFromCampaign(campaignId: number, productId: number) {
|
|
return this.request<any>(`/api/campaigns/${campaignId}/products/${productId}`, {
|
|
method: 'DELETE',
|
|
});
|
|
}
|
|
|
|
// Analytics
|
|
async getAnalyticsOverview(days?: number) {
|
|
return this.request<any>(`/api/analytics/overview${days ? `?days=${days}` : ''}`);
|
|
}
|
|
|
|
async getProductAnalytics(id: number, days?: number) {
|
|
return this.request<any>(`/api/analytics/products/${id}${days ? `?days=${days}` : ''}`);
|
|
}
|
|
|
|
async getCampaignAnalytics(id: number, days?: number) {
|
|
return this.request<any>(`/api/analytics/campaigns/${id}${days ? `?days=${days}` : ''}`);
|
|
}
|
|
|
|
// Settings
|
|
async getSettings() {
|
|
return this.request<{ settings: any[] }>('/api/settings');
|
|
}
|
|
|
|
async updateSetting(key: string, value: string) {
|
|
return this.request<any>(`/api/settings/${key}`, {
|
|
method: 'PUT',
|
|
body: JSON.stringify({ value }),
|
|
});
|
|
}
|
|
|
|
async updateSettings(settings: Array<{ key: string; value: string }>) {
|
|
return this.request<any>('/api/settings', {
|
|
method: 'PUT',
|
|
body: JSON.stringify({ settings }),
|
|
});
|
|
}
|
|
|
|
// Proxies
|
|
async getProxies() {
|
|
return this.request<{ proxies: any[] }>('/api/proxies');
|
|
}
|
|
|
|
async getProxy(id: number) {
|
|
return this.request<any>(`/api/proxies/${id}`);
|
|
}
|
|
|
|
async addProxy(data: any) {
|
|
return this.request<any>('/api/proxies', {
|
|
method: 'POST',
|
|
body: JSON.stringify(data),
|
|
});
|
|
}
|
|
|
|
async addProxiesBulk(proxies: any[]) {
|
|
return this.request<any>('/api/proxies/bulk', {
|
|
method: 'POST',
|
|
body: JSON.stringify({ proxies }),
|
|
});
|
|
}
|
|
|
|
async testProxy(id: number) {
|
|
return this.request<any>(`/api/proxies/${id}/test`, {
|
|
method: 'POST',
|
|
});
|
|
}
|
|
|
|
async testAllProxies() {
|
|
return this.request<{ jobId: number; message: string }>('/api/proxies/test-all', {
|
|
method: 'POST',
|
|
});
|
|
}
|
|
|
|
async getProxyTestJob(jobId: number) {
|
|
return this.request<{ job: any }>(`/api/proxies/test-job/${jobId}`);
|
|
}
|
|
|
|
async getActiveProxyTestJob() {
|
|
return this.request<{ job: any }>('/api/proxies/test-job');
|
|
}
|
|
|
|
async cancelProxyTestJob(jobId: number) {
|
|
return this.request<{ message: string }>(`/api/proxies/test-job/${jobId}/cancel`, {
|
|
method: 'POST',
|
|
});
|
|
}
|
|
|
|
async updateProxy(id: number, data: any) {
|
|
return this.request<any>(`/api/proxies/${id}`, {
|
|
method: 'PUT',
|
|
body: JSON.stringify(data),
|
|
});
|
|
}
|
|
|
|
async deleteProxy(id: number) {
|
|
return this.request<any>(`/api/proxies/${id}`, {
|
|
method: 'DELETE',
|
|
});
|
|
}
|
|
|
|
async updateProxyLocations() {
|
|
return this.request<{ message: string }>('/api/proxies/update-locations', {
|
|
method: 'POST',
|
|
});
|
|
}
|
|
|
|
// Logs
|
|
async getLogs(limit?: number, level?: string, category?: string) {
|
|
const params = new URLSearchParams();
|
|
if (limit) params.append('limit', limit.toString());
|
|
if (level) params.append('level', level);
|
|
if (category) params.append('category', category);
|
|
|
|
return this.request<{ logs: any[] }>(`/api/logs?${params.toString()}`);
|
|
}
|
|
|
|
async clearLogs() {
|
|
return this.request<any>('/api/logs', {
|
|
method: 'DELETE',
|
|
});
|
|
}
|
|
|
|
// Scraper Monitor
|
|
async getActiveScrapers() {
|
|
return this.request<any>('/api/scraper-monitor/active');
|
|
}
|
|
|
|
async getScraperHistory(storeId?: number) {
|
|
const params = storeId ? `?store_id=${storeId}` : '';
|
|
return this.request<any>(`/api/scraper-monitor/history${params}`);
|
|
}
|
|
|
|
async getJobStats(dispensaryId?: number) {
|
|
const params = dispensaryId ? `?dispensary_id=${dispensaryId}` : '';
|
|
return this.request<any>(`/api/scraper-monitor/jobs/stats${params}`);
|
|
}
|
|
|
|
async getActiveJobs(dispensaryId?: number) {
|
|
const params = dispensaryId ? `?dispensary_id=${dispensaryId}` : '';
|
|
return this.request<any>(`/api/scraper-monitor/jobs/active${params}`);
|
|
}
|
|
|
|
async getRecentJobs(options?: { limit?: number; dispensaryId?: number; status?: string }) {
|
|
const params = new URLSearchParams();
|
|
if (options?.limit) params.append('limit', options.limit.toString());
|
|
if (options?.dispensaryId) params.append('dispensary_id', options.dispensaryId.toString());
|
|
if (options?.status) params.append('status', options.status);
|
|
const queryString = params.toString() ? `?${params.toString()}` : '';
|
|
return this.request<any>(`/api/scraper-monitor/jobs/recent${queryString}`);
|
|
}
|
|
|
|
async getWorkerStats(dispensaryId?: number) {
|
|
const params = dispensaryId ? `?dispensary_id=${dispensaryId}` : '';
|
|
return this.request<any>(`/api/scraper-monitor/jobs/workers${params}`);
|
|
}
|
|
|
|
// Change Approval
|
|
async getChanges(status?: 'pending' | 'approved' | 'rejected') {
|
|
const params = status ? `?status=${status}` : '';
|
|
return this.request<{ changes: any[] }>(`/api/changes${params}`);
|
|
}
|
|
|
|
async getChangeStats() {
|
|
return this.request<{
|
|
pending_count: number;
|
|
pending_recrawl_count: number;
|
|
approved_count: number;
|
|
rejected_count: number;
|
|
}>('/api/changes/stats');
|
|
}
|
|
|
|
async approveChange(changeId: number) {
|
|
return this.request<{
|
|
message: string;
|
|
dispensary: any;
|
|
requires_recrawl: boolean;
|
|
}>(`/api/changes/${changeId}/approve`, {
|
|
method: 'POST',
|
|
});
|
|
}
|
|
|
|
async rejectChange(changeId: number, reason?: string) {
|
|
return this.request<{
|
|
message: string;
|
|
change: any;
|
|
}>(`/api/changes/${changeId}/reject`, {
|
|
method: 'POST',
|
|
body: JSON.stringify({ reason }),
|
|
});
|
|
}
|
|
|
|
// Dispensary Products, Brands, Specials
|
|
async getDispensaryProducts(slug: string, category?: number) {
|
|
const params = category ? `?category=${category}` : '';
|
|
return this.request<{ products: any[] }>(`/api/dispensaries/${slug}/products${params}`);
|
|
}
|
|
|
|
async getDispensaryBrands(slug: string) {
|
|
return this.request<{ brands: Array<{ brand: string; product_count: number }> }>(`/api/dispensaries/${slug}/brands`);
|
|
}
|
|
|
|
async getDispensarySpecials(slug: string) {
|
|
return this.request<{ specials: any[] }>(`/api/dispensaries/${slug}/specials`);
|
|
}
|
|
|
|
// API Permissions
|
|
async getApiPermissions() {
|
|
return this.request<{ permissions: any[] }>('/api/api-permissions');
|
|
}
|
|
|
|
async createApiPermission(data: { user_name: string; allowed_ips?: string; allowed_domains?: string }) {
|
|
return this.request<{ permission: any; message: string }>('/api/api-permissions', {
|
|
method: 'POST',
|
|
body: JSON.stringify(data),
|
|
});
|
|
}
|
|
|
|
async updateApiPermission(id: number, data: { user_name?: string; allowed_ips?: string; allowed_domains?: string; is_active?: number }) {
|
|
return this.request<{ permission: any }>(`/api/api-permissions/${id}`, {
|
|
method: 'PUT',
|
|
body: JSON.stringify(data),
|
|
});
|
|
}
|
|
|
|
async toggleApiPermission(id: number) {
|
|
return this.request<{ permission: any }>(`/api/api-permissions/${id}/toggle`, {
|
|
method: 'PATCH',
|
|
});
|
|
}
|
|
|
|
async deleteApiPermission(id: number) {
|
|
return this.request<{ message: string }>(`/api/api-permissions/${id}`, {
|
|
method: 'DELETE',
|
|
});
|
|
}
|
|
|
|
// Crawler Schedule
|
|
async getGlobalSchedule() {
|
|
return this.request<{ schedules: any[] }>('/api/schedule/global');
|
|
}
|
|
|
|
async updateGlobalSchedule(type: string, data: { enabled?: boolean; interval_hours?: number; run_time?: string }) {
|
|
return this.request<{ schedule: any; message: string }>(`/api/schedule/global/${type}`, {
|
|
method: 'PUT',
|
|
body: JSON.stringify(data),
|
|
});
|
|
}
|
|
|
|
async getStoreSchedules() {
|
|
return this.request<{ stores: any[] }>('/api/schedule/stores');
|
|
}
|
|
|
|
async getStoreSchedule(storeId: number) {
|
|
return this.request<{ schedule: any }>(`/api/schedule/stores/${storeId}`);
|
|
}
|
|
|
|
async updateStoreSchedule(storeId: number, data: any) {
|
|
return this.request<{ schedule: any }>(`/api/schedule/stores/${storeId}`, {
|
|
method: 'PUT',
|
|
body: JSON.stringify(data),
|
|
});
|
|
}
|
|
|
|
async getCrawlJobs(limit?: number) {
|
|
const params = limit ? `?limit=${limit}` : '';
|
|
return this.request<{ jobs: any[] }>(`/api/schedule/jobs${params}`);
|
|
}
|
|
|
|
async getStoreCrawlJobs(storeId: number, limit?: number) {
|
|
const params = limit ? `?limit=${limit}` : '';
|
|
return this.request<{ jobs: any[] }>(`/api/schedule/jobs/store/${storeId}${params}`);
|
|
}
|
|
|
|
async cancelCrawlJob(jobId: number) {
|
|
return this.request<{ success: boolean; message: string }>(`/api/schedule/jobs/${jobId}/cancel`, {
|
|
method: 'POST',
|
|
});
|
|
}
|
|
|
|
async triggerStoreCrawl(storeId: number) {
|
|
return this.request<{ job: any; message: string }>(`/api/schedule/trigger/store/${storeId}`, {
|
|
method: 'POST',
|
|
});
|
|
}
|
|
|
|
async triggerAllCrawls() {
|
|
return this.request<{ jobs_created: number; message: string }>('/api/schedule/trigger/all', {
|
|
method: 'POST',
|
|
});
|
|
}
|
|
|
|
async restartScheduler() {
|
|
return this.request<{ message: string }>('/api/schedule/restart', {
|
|
method: 'POST',
|
|
});
|
|
}
|
|
}
|
|
|
|
export const api = new ApiClient(API_URL);
|