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:
@@ -30,6 +30,7 @@ class Api::V1::AccountsController < Api::BaseController
|
||||
locale: account_params[:locale],
|
||||
user: current_user
|
||||
).perform
|
||||
enqueue_branding_enrichment
|
||||
if @user
|
||||
# Authenticated users (dashboard "add account") and api_only signups
|
||||
# need the full response with account_id. API-only deployments have no
|
||||
@@ -69,6 +70,16 @@ class Api::V1::AccountsController < Api::BaseController
|
||||
|
||||
private
|
||||
|
||||
def enqueue_branding_enrichment
|
||||
return if account_params[:email].blank?
|
||||
|
||||
Account::BrandingEnrichmentJob.perform_later(@account.id, account_params[:email])
|
||||
Redis::Alfred.set(format(Redis::Alfred::ACCOUNT_ONBOARDING_ENRICHMENT, account_id: @account.id), '1', ex: 30)
|
||||
rescue StandardError => e
|
||||
# Enrichment is optional — never let queue/Redis failures abort signup
|
||||
ChatwootExceptionTracker.new(e).capture_exception
|
||||
end
|
||||
|
||||
def ensure_account_name
|
||||
# ensure that account_name and user_full_name is present
|
||||
# this is becuase the account builder and the models validations are not triggered
|
||||
|
||||
32
app/jobs/account/branding_enrichment_job.rb
Normal file
32
app/jobs/account/branding_enrichment_job.rb
Normal file
@@ -0,0 +1,32 @@
|
||||
class Account::BrandingEnrichmentJob < ApplicationJob
|
||||
queue_as :low
|
||||
|
||||
def perform(account_id, email)
|
||||
result = WebsiteBrandingService.new(email).perform
|
||||
return if result.blank?
|
||||
|
||||
account = Account.find(account_id)
|
||||
account.name = result[:title] if result[:title].present?
|
||||
account.custom_attributes['brand_info'] = result if account.custom_attributes['brand_info'].blank?
|
||||
account.save! if account.changed?
|
||||
ensure
|
||||
finish_enrichment(account_id)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def finish_enrichment(account_id)
|
||||
Redis::Alfred.delete(format(Redis::Alfred::ACCOUNT_ONBOARDING_ENRICHMENT, account_id: account_id))
|
||||
|
||||
account = Account.find(account_id)
|
||||
if account.custom_attributes['onboarding_step'] == 'enrichment'
|
||||
account.custom_attributes['onboarding_step'] = 'account_details'
|
||||
account.save!
|
||||
end
|
||||
|
||||
user = account.administrators.first
|
||||
return unless user
|
||||
|
||||
ActionCableBroadcastJob.perform_later([user.pubsub_token], 'account.enrichment_completed', { account_id: account_id })
|
||||
end
|
||||
end
|
||||
@@ -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