Files
chatwoot/app/services/website_branding_service.rb

150 lines
4.3 KiB
Ruby

require 'resolv'
class WebsiteBrandingService
include SocialLinkParser
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
doc = fetch_page
return nil if doc.nil?
links = extract_links(doc)
DATA_DEFAULTS.merge({
domain: @domain,
title: extract_title(doc),
colors: extract_colors(doc),
logos: extract_logos(doc),
socials: build_socials(links),
email: @email,
email_provider: detect_email_provider
})
rescue StandardError => e
Rails.logger.error "[WebsiteBranding] #{e.message}"
nil
end
private
def fetch_page
body = nil
SafeFetch.fetch(@url, validate_content_type: false) do |result|
body = result.tempfile.read
end
@http_status = 200
return nil if body.blank?
Nokogiri::HTML(body)
rescue SafeFetch::HttpError => e
@http_status = e.message.to_i
nil
rescue SafeFetch::Error => e
Rails.logger.error "[WebsiteBranding] Failed to fetch #{@url}: #{e.message}"
nil
end
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?
title = doc.at_xpath('//title')&.text
title&.strip&.split(/\s*[|\-–—·:]+\s*/)&.first
end
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)
doc.css('a[href]').filter_map do |a|
href = a['href']&.strip
next if href.blank? || href.start_with?('#', 'javascript:', 'mailto:', 'tel:')
href.start_with?('http') ? href : URI.join(@url, href).to_s
rescue URI::InvalidURIError
nil
end.uniq
end
def resolve_url(url)
return nil if url.blank?
return url if url.start_with?('http')
URI.join(@url, url).to_s
rescue URI::InvalidURIError
nil
end
GOOGLE_MX_DOMAINS = %w[google.com googlemail.com].freeze
MICROSOFT_MX_DOMAINS = %w[outlook.com].freeze
# Probes the domain's MX records to infer the mailbox provider, returning
# 'google' or 'microsoft' (matching Channel::Email#provider) or nil when unknown.
def detect_email_provider
hosts = mx_records
return 'google' if mx_hosted_by?(hosts, GOOGLE_MX_DOMAINS)
return 'microsoft' if mx_hosted_by?(hosts, MICROSOFT_MX_DOMAINS)
nil
end
# Matches on the registrable domain of the MX host (anchored on a label
# boundary) so lookalikes like "notgoogle.com" don't get misclassified.
def mx_hosted_by?(hosts, provider_domains)
hosts.any? do |host|
provider_domains.any? { |domain| host == domain || host.end_with?(".#{domain}") }
end
end
def mx_records
Resolv::DNS.open do |resolver|
resolver.timeouts = 5
resolver.getresources(@domain, Resolv::DNS::Resource::IN::MX).map { |record| record.exchange.to_s.downcase }
end
rescue StandardError => e
Rails.logger.error "[WebsiteBranding] MX probe failed for #{@domain}: #{e.message}"
[]
end
end
WebsiteBrandingService.prepend_mod_with('WebsiteBrandingService')