150 lines
4.3 KiB
Ruby
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')
|