feat: Treez SSR support, task improvements, worker geo display
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful

- Add SSR config extraction for Treez sites (BEST Dispensary)
- Increase MAX_RETRIES from 3 to 5 for task failures
- Update task list ordering: active > pending > failed > completed
- Show detected proxy location in worker dashboard (from fingerprint)
- Hardcode 'dutchie' menu_type in promotion.ts (remove deriveMenuType)
- Update provider display to show actual provider names

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Kelly
2025-12-16 19:22:04 -07:00
parent 7d65e0ae59
commit 3ee09fbe84
6 changed files with 173 additions and 62 deletions

View File

@@ -151,18 +151,6 @@ function generateSlug(name: string, city: string, state: string): string {
return base; return base;
} }
/**
* Derive menu_type from platform_menu_url pattern
*/
function deriveMenuType(url: string | null): string {
if (!url) return 'unknown';
if (url.includes('/dispensary/')) return 'standalone';
if (url.includes('/embedded-menu/')) return 'embedded';
if (url.includes('/stores/')) return 'standalone';
// Custom domain = embedded widget on store's site
if (!url.includes('dutchie.com')) return 'embedded';
return 'unknown';
}
/** /**
* Log a promotion action to dutchie_promotion_log * Log a promotion action to dutchie_promotion_log
@@ -415,7 +403,7 @@ async function promoteLocation(
loc.timezone, // $15 timezone loc.timezone, // $15 timezone
loc.platform_location_id, // $16 platform_dispensary_id loc.platform_location_id, // $16 platform_dispensary_id
loc.platform_menu_url, // $17 menu_url loc.platform_menu_url, // $17 menu_url
deriveMenuType(loc.platform_menu_url), // $18 menu_type 'dutchie', // $18 menu_type
loc.description, // $19 description loc.description, // $19 description
loc.logo_image, // $20 logo_image loc.logo_image, // $20 logo_image
loc.banner_image, // $21 banner_image loc.banner_image, // $21 banner_image

View File

@@ -289,6 +289,102 @@ export function getStoreConfig(): TreezStoreConfig | null {
return currentStoreConfig; return currentStoreConfig;
} }
/**
* Extract store config from page HTML for SSR sites.
*
* SSR sites (like BEST Dispensary) pre-render data and don't make client-side
* API requests. The config is embedded in __NEXT_DATA__ or window variables.
*
* Looks for:
* - __NEXT_DATA__.props.pageProps.msoStoreConfig.orgId / entityId
* - window.__SETTINGS__.msoOrgId / msoStoreEntityId
* - treezStores config in page data
*/
async function extractConfigFromPage(page: Page): Promise<TreezStoreConfig | null> {
console.log('[Treez Client] Attempting to extract config from page HTML (SSR fallback)...');
const config = await page.evaluate(() => {
// Try __NEXT_DATA__ first (Next.js SSR)
const nextDataEl = document.getElementById('__NEXT_DATA__');
if (nextDataEl) {
try {
const nextData = JSON.parse(nextDataEl.textContent || '{}');
const pageProps = nextData?.props?.pageProps;
// Look for MSO config in various locations
const msoConfig = pageProps?.msoStoreConfig || pageProps?.storeConfig || {};
const settings = pageProps?.settings || {};
// Extract org-id and entity-id
let orgId = msoConfig.orgId || msoConfig.msoOrgId || settings.msoOrgId;
let entityId = msoConfig.entityId || msoConfig.msoStoreEntityId || settings.msoStoreEntityId;
// Also check treezStores array
if (!orgId || !entityId) {
const treezStores = pageProps?.treezStores || nextData?.props?.treezStores;
if (treezStores && Array.isArray(treezStores) && treezStores.length > 0) {
const store = treezStores[0];
orgId = orgId || store.orgId || store.organization_id;
entityId = entityId || store.entityId || store.entity_id || store.storeId;
}
}
// Check for API settings
const apiSettings = pageProps?.apiSettings || settings.api || {};
if (orgId && entityId) {
return {
orgId,
entityId,
esUrl: apiSettings.esUrl || null,
apiKey: apiSettings.apiKey || null,
};
}
} catch (e) {
console.error('Error parsing __NEXT_DATA__:', e);
}
}
// Try window variables
const win = window as any;
if (win.__SETTINGS__) {
const s = win.__SETTINGS__;
if (s.msoOrgId && s.msoStoreEntityId) {
return {
orgId: s.msoOrgId,
entityId: s.msoStoreEntityId,
esUrl: s.esUrl || null,
apiKey: s.apiKey || null,
};
}
}
return null;
});
if (!config || !config.orgId || !config.entityId) {
console.log('[Treez Client] Could not extract config from page');
return null;
}
// Build full config with defaults for missing values
const fullConfig: TreezStoreConfig = {
orgId: config.orgId,
entityId: config.entityId,
// Default ES URL pattern - gapcommerce is the common tenant
esUrl: config.esUrl || 'https://search-gapcommerce.gapcommerceapi.com/product/search',
// Use default API key from config
apiKey: config.apiKey || TREEZ_CONFIG.esApiKey,
};
console.log('[Treez Client] Extracted config from page (SSR):');
console.log(` ES URL: ${fullConfig.esUrl}`);
console.log(` Org ID: ${fullConfig.orgId}`);
console.log(` Entity ID: ${fullConfig.entityId}`);
return fullConfig;
}
// ============================================================ // ============================================================
// PRODUCT FETCHING (Direct API Approach) // PRODUCT FETCHING (Direct API Approach)
// ============================================================ // ============================================================
@@ -343,9 +439,15 @@ export async function fetchAllProducts(
// Wait for initial page load to trigger first API request // Wait for initial page load to trigger first API request
await sleep(3000); await sleep(3000);
// Check if we captured the store config // Check if we captured the store config from network requests
if (!currentStoreConfig) { if (!currentStoreConfig) {
console.error('[Treez Client] Failed to capture store config from browser requests'); console.log('[Treez Client] No API requests captured - trying SSR fallback...');
// For SSR sites, extract config from page HTML
currentStoreConfig = await extractConfigFromPage(page);
}
if (!currentStoreConfig) {
console.error('[Treez Client] Failed to capture store config from browser requests or page HTML');
throw new Error('Failed to capture Treez store config'); throw new Error('Failed to capture Treez store config');
} }

View File

@@ -267,7 +267,7 @@ class TaskService {
async completeTask(taskId: number, result?: Record<string, unknown>): Promise<boolean> { async completeTask(taskId: number, result?: Record<string, unknown>): Promise<boolean> {
await pool.query( await pool.query(
`UPDATE worker_tasks `UPDATE worker_tasks
SET status = 'completed', completed_at = NOW(), result = $2 SET status = 'completed', completed_at = NOW(), result = $2, error_message = NULL
WHERE id = $1`, WHERE id = $1`,
[taskId, result ? JSON.stringify(result) : null] [taskId, result ? JSON.stringify(result) : null]
); );
@@ -351,7 +351,7 @@ class TaskService {
* Hard failures: Auto-retry up to MAX_RETRIES with exponential backoff * Hard failures: Auto-retry up to MAX_RETRIES with exponential backoff
*/ */
async failTask(taskId: number, errorMessage: string): Promise<boolean> { async failTask(taskId: number, errorMessage: string): Promise<boolean> {
const MAX_RETRIES = 3; const MAX_RETRIES = 5;
const isSoft = this.isSoftFailure(errorMessage); const isSoft = this.isSoftFailure(errorMessage);
// Get current retry count // Get current retry count
@@ -490,7 +490,15 @@ class TaskService {
${poolJoin} ${poolJoin}
LEFT JOIN worker_registry w ON w.worker_id = t.worker_id LEFT JOIN worker_registry w ON w.worker_id = t.worker_id
${whereClause} ${whereClause}
ORDER BY t.created_at DESC ORDER BY
CASE t.status
WHEN 'active' THEN 1
WHEN 'pending' THEN 2
WHEN 'failed' THEN 3
WHEN 'completed' THEN 4
ELSE 5
END,
t.created_at DESC
LIMIT ${limit} OFFSET ${offset}`, LIMIT ${limit} OFFSET ${offset}`,
params params
); );

View File

@@ -1,29 +1,36 @@
/** /**
* Provider Display Names * Provider Display Names
* *
* Maps internal provider identifiers to safe display labels. * Maps internal menu_type values to display labels.
* Internal identifiers (menu_type, product_provider, crawler_type) remain unchanged. * - standalone/embedded → dutchie (both are Dutchie platform)
* Only the display label shown to users is transformed. * - treez → treez
* - jane/iheartjane → jane
*/ */
export const ProviderDisplayNames: Record<string, string> = { export const ProviderDisplayNames: Record<string, string> = {
// All menu providers map to anonymous "Menu Feed" label // Dutchie (standalone and embedded are both Dutchie)
dutchie: 'Menu Feed', dutchie: 'dutchie',
treez: 'Menu Feed', standalone: 'dutchie',
jane: 'Menu Feed', embedded: 'dutchie',
iheartjane: 'Menu Feed',
blaze: 'Menu Feed', // Other platforms
flowhub: 'Menu Feed', treez: 'treez',
weedmaps: 'Menu Feed', jane: 'jane',
leafly: 'Menu Feed', iheartjane: 'jane',
leaflogix: 'Menu Feed',
tymber: 'Menu Feed', // Future platforms
dispense: 'Menu Feed', blaze: 'blaze',
flowhub: 'flowhub',
weedmaps: 'weedmaps',
leafly: 'leafly',
leaflogix: 'leaflogix',
tymber: 'tymber',
dispense: 'dispense',
// Catch-all // Catch-all
unknown: 'Menu Feed', unknown: 'unknown',
default: 'Menu Feed', default: 'unknown',
'': 'Menu Feed', '': 'unknown',
}; };
/** /**

View File

@@ -1,32 +1,36 @@
/** /**
* Provider Display Names * Provider Display Names
* *
* Maps internal provider identifiers to safe display labels. * Maps internal menu_type values to display labels.
* Internal identifiers (menu_type, product_provider, crawler_type) remain unchanged. * - standalone/embedded → Dutchie (both are Dutchie platform)
* Only the display label shown to users is transformed. * - treez → Treez
* * - jane/iheartjane → Jane
* IMPORTANT: Raw provider names (dutchie, treez, jane, etc.) must NEVER
* be displayed directly in the UI. Always use this utility.
*/ */
export const ProviderDisplayNames: Record<string, string> = { export const ProviderDisplayNames: Record<string, string> = {
// All menu providers map to anonymous "Menu Feed" label // Dutchie (standalone and embedded are both Dutchie)
dutchie: 'Menu Feed', dutchie: 'dutchie',
treez: 'Menu Feed', standalone: 'dutchie',
jane: 'Menu Feed', embedded: 'dutchie',
iheartjane: 'Menu Feed',
blaze: 'Menu Feed', // Other platforms
flowhub: 'Menu Feed', treez: 'treez',
weedmaps: 'Menu Feed', jane: 'jane',
leafly: 'Menu Feed', iheartjane: 'jane',
leaflogix: 'Menu Feed',
tymber: 'Menu Feed', // Future platforms
dispense: 'Menu Feed', blaze: 'blaze',
flowhub: 'flowhub',
weedmaps: 'weedmaps',
leafly: 'leafly',
leaflogix: 'leaflogix',
tymber: 'tymber',
dispense: 'dispense',
// Catch-all // Catch-all
unknown: 'Menu Feed', unknown: 'unknown',
default: 'Menu Feed', default: 'unknown',
'': 'Menu Feed', '': 'unknown',
}; };
/** /**

View File

@@ -383,9 +383,10 @@ function PreflightSummary({ worker, poolOpen = true }: { worker: Worker; poolOpe
const fingerprint = worker.fingerprint_data; const fingerprint = worker.fingerprint_data;
const httpError = worker.preflight_http_error; const httpError = worker.preflight_http_error;
const httpMs = worker.preflight_http_ms; const httpMs = worker.preflight_http_ms;
// Geo from current_city/state columns, or fallback to fingerprint detected location // Show DETECTED proxy location (from fingerprint), not assigned state
const geoState = worker.current_state || fingerprint?.detectedLocation?.region; // This lets us verify the proxy is geo-targeted correctly
const geoCity = worker.current_city || fingerprint?.detectedLocation?.city; const geoState = fingerprint?.detectedLocation?.region || worker.current_state;
const geoCity = fingerprint?.detectedLocation?.city || worker.current_city;
// Worker is ONLY qualified if http preflight passed AND has geo assigned // Worker is ONLY qualified if http preflight passed AND has geo assigned
const hasGeo = Boolean(geoState); const hasGeo = Boolean(geoState);
const isQualified = (worker.is_qualified || httpStatus === 'passed') && hasGeo; const isQualified = (worker.is_qualified || httpStatus === 'passed') && hasGeo;
@@ -702,8 +703,9 @@ function WorkerSlot({
const httpIp = worker?.http_ip; const httpIp = worker?.http_ip;
const fingerprint = worker?.fingerprint_data; const fingerprint = worker?.fingerprint_data;
const geoState = worker?.current_state || (fingerprint as any)?.detectedLocation?.region; // Show DETECTED proxy location (from fingerprint), not assigned state
const geoCity = worker?.current_city || (fingerprint as any)?.detectedLocation?.city; const geoState = (fingerprint as any)?.detectedLocation?.region || worker?.current_state;
const geoCity = (fingerprint as any)?.detectedLocation?.city || worker?.current_city;
const isQualified = worker?.is_qualified; const isQualified = worker?.is_qualified;
// Build fingerprint tooltip // Build fingerprint tooltip