feat: account enrichment using context.dev [UPM-27] (#13978)
## Account branding enrichment during signup This PR does the following ### Replace Firecrawl with Context.dev Switches the enterprise brand lookup from Firecrawl to Context.dev for better data quality, built-in caching, and automatic filtering of free/disposable email providers. The service interface changes from URL to email input to match Context.dev's email endpoint. OSS still falls back to basic HTML scraping with a normalized output shape across both paths. The enterprise path intentionally does not fall back to HTML scraping on failure — speed matters more than completeness. We want the user on the editable onboarding form fast, and a slow fallback scrape is worse than letting them fill it in. Requires `CONTEXT_DEV_API_KEY` in Super Admin → App Config. Without it, falls back to OSS HTML scraping. ### Add job to enrich account details After account creation, `Account::BrandingEnrichmentJob` looks up the signup email and pre-fills the account name, colors, logos, social links, and industry into `custom_attributes['brand_info']`. The job signals completion via a short-lived Redis key (30s TTL) + an ActionCable broadcast (`account.enrichment_completed`). The Redis key lets the frontend distinguish "still running" from "finished with no results."
This commit is contained in:
@@ -1,8 +1,15 @@
|
||||
class WebsiteBrandingService
|
||||
include SocialLinkParser
|
||||
|
||||
def initialize(url)
|
||||
@url = normalize_url(url)
|
||||
attr_reader :http_status
|
||||
|
||||
DATA_DEFAULTS = { description: nil, slogan: nil, phone: nil, address: nil, links: nil, stock: nil, industries: [], is_nsfw: false }.freeze
|
||||
|
||||
def initialize(email)
|
||||
@email = email
|
||||
@domain = email.split('@').last&.downcase&.strip
|
||||
@url = "https://#{@domain}"
|
||||
@http_status = nil
|
||||
end
|
||||
|
||||
def perform
|
||||
@@ -11,13 +18,14 @@ class WebsiteBrandingService
|
||||
|
||||
links = extract_links(doc)
|
||||
|
||||
{
|
||||
business_name: extract_business_name(doc),
|
||||
language: extract_language(doc),
|
||||
industry_category: nil,
|
||||
social_handles: extract_social_from_links(links),
|
||||
branding: extract_branding(doc)
|
||||
}
|
||||
DATA_DEFAULTS.merge({
|
||||
domain: @domain,
|
||||
title: extract_title(doc),
|
||||
colors: extract_colors(doc),
|
||||
logos: extract_logos(doc),
|
||||
socials: build_socials(links),
|
||||
email: @email
|
||||
})
|
||||
rescue StandardError => e
|
||||
Rails.logger.error "[WebsiteBranding] #{e.message}"
|
||||
nil
|
||||
@@ -25,12 +33,9 @@ class WebsiteBrandingService
|
||||
|
||||
private
|
||||
|
||||
def normalize_url(url)
|
||||
url.match?(%r{\Ahttps?://}) ? url : "https://#{url}"
|
||||
end
|
||||
|
||||
def fetch_page
|
||||
response = HTTParty.get(@url, follow_redirects: true, timeout: 15)
|
||||
@http_status = response.code
|
||||
return nil unless response.success?
|
||||
|
||||
Nokogiri::HTML(response.body)
|
||||
@@ -39,7 +44,7 @@ class WebsiteBrandingService
|
||||
nil
|
||||
end
|
||||
|
||||
def extract_business_name(doc)
|
||||
def extract_title(doc)
|
||||
og_site_name = doc.at_css('meta[property="og:site_name"]')&.[]('content')
|
||||
return og_site_name.strip if og_site_name.present?
|
||||
|
||||
@@ -47,8 +52,37 @@ class WebsiteBrandingService
|
||||
title&.strip&.split(/\s*[|\-–—·:]+\s*/)&.first
|
||||
end
|
||||
|
||||
def extract_language(doc)
|
||||
doc.at_css('html')&.[]('lang')&.split('-')&.first&.downcase
|
||||
def extract_colors(doc)
|
||||
color = doc.at_css('meta[name="theme-color"]')&.[]('content')
|
||||
return [] if color.blank?
|
||||
|
||||
[{ hex: color, name: nil }]
|
||||
end
|
||||
|
||||
def extract_logos(doc)
|
||||
favicon = doc.at_css('link[rel*="icon"]')&.[]('href')
|
||||
return [] if favicon.blank?
|
||||
|
||||
url = resolve_url(favicon)
|
||||
return [] if url.blank?
|
||||
|
||||
[{ url: url, type: nil, mode: nil, colors: [], resolution: { aspect_ratio: 1 } }]
|
||||
end
|
||||
|
||||
def build_socials(links)
|
||||
handles = extract_social_from_links(links)
|
||||
handles.filter_map do |platform, handle|
|
||||
next if handle.blank?
|
||||
|
||||
url = reconstruct_social_url(platform, handle)
|
||||
{ type: platform.to_s, url: url }
|
||||
end
|
||||
end
|
||||
|
||||
def reconstruct_social_url(platform, handle)
|
||||
base_urls = { whatsapp: 'https://wa.me/', line: 'https://line.me/', facebook: 'https://facebook.com/',
|
||||
instagram: 'https://instagram.com/', telegram: 'https://t.me/', tiktok: 'https://tiktok.com/' }
|
||||
"#{base_urls[platform]}#{handle}"
|
||||
end
|
||||
|
||||
def extract_links(doc)
|
||||
@@ -62,24 +96,6 @@ class WebsiteBrandingService
|
||||
end.uniq
|
||||
end
|
||||
|
||||
def extract_branding(doc)
|
||||
{
|
||||
favicon: extract_favicon(doc),
|
||||
primary_color: extract_theme_color(doc)
|
||||
}
|
||||
end
|
||||
|
||||
def extract_favicon(doc)
|
||||
favicon = doc.at_css('link[rel*="icon"]')&.[]('href')
|
||||
return nil if favicon.blank?
|
||||
|
||||
resolve_url(favicon)
|
||||
end
|
||||
|
||||
def extract_theme_color(doc)
|
||||
doc.at_css('meta[name="theme-color"]')&.[]('content')
|
||||
end
|
||||
|
||||
def resolve_url(url)
|
||||
return nil if url.blank?
|
||||
return url if url.start_with?('http')
|
||||
|
||||
Reference in New Issue
Block a user