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:
Shivam Mishra
2026-04-08 11:16:52 +05:30
committed by GitHub
parent 871f2f4d56
commit e5107604a0
10 changed files with 250 additions and 288 deletions

View File

@@ -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

View 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

View File

@@ -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')