.vbw-planning/milestones/polish-and-reliability/phases/02-favicon-support/PLAN-01.md
Build the complete backend infrastructure for favicon support: configuration settings, Source model attachment, favicon discovery logic, and the background job that orchestrates it all. REQ-FAV-01, REQ-FAV-02.
@lib/source_monitor/configuration/images_settings.rb -- reference pattern for settings class (attr_accessors, reset!, constants)@lib/source_monitor/configuration.rb -- where to add @favicons instance and attr_reader (line 31, line 55)@app/models/source_monitor/source.rb -- add has_one_attached :favicon with guard (line 9 area, after includes)@app/models/source_monitor/item_content.rb -- proven ActiveStorage guard pattern: has_many_attached :images if defined?(ActiveStorage)@app/jobs/source_monitor/download_content_images_job.rb -- reference job pattern (find_by, early returns, blob create_and_upload!)@lib/source_monitor/images/downloader.rb -- reference for HTTP image download with validation@lib/source_monitor.rb -- autoload declarations (lines 90-93 for Images module)@lib/source_monitor/http.rb -- HTTP.client() for Faraday requestsFiles: lib/source_monitor/configuration/favicons_settings.rb
Create a new settings class following the ImagesSettings pattern:
# frozen_string_literal: true
module SourceMonitor
class Configuration
class FaviconsSettings
attr_accessor :enabled,
:fetch_timeout,
:max_download_size,
:retry_cooldown_days,
:allowed_content_types
DEFAULT_FETCH_TIMEOUT = 5 # seconds
DEFAULT_MAX_DOWNLOAD_SIZE = 1 * 1024 * 1024 # 1 MB
DEFAULT_RETRY_COOLDOWN_DAYS = 7
DEFAULT_ALLOWED_CONTENT_TYPES = %w[
image/x-icon
image/vnd.microsoft.icon
image/png
image/jpeg
image/gif
image/svg+xml
image/webp
].freeze
def initialize
reset!
end
def reset!
@enabled = true
@fetch_timeout = DEFAULT_FETCH_TIMEOUT
@max_download_size = DEFAULT_MAX_DOWNLOAD_SIZE
@retry_cooldown_days = DEFAULT_RETRY_COOLDOWN_DAYS
@allowed_content_types = DEFAULT_ALLOWED_CONTENT_TYPES.dup
end
def enabled?
!!enabled && defined?(ActiveStorage)
end
end
end
end
Then wire it into lib/source_monitor/configuration.rb:
require "source_monitor/configuration/favicons_settings" after the images_settings require (line 11):favicons to the attr_reader list (line 31)initialize, add @favicons = FaviconsSettings.new after @images (line 55)Tests: test/lib/source_monitor/configuration/favicons_settings_test.rb
Files: app/models/source_monitor/source.rb
Add the Active Storage attachment with the proven guard pattern. Insert after the existing includes (around line 9), before the FETCH_STATUS_VALUES constant:
has_one_attached :favicon if defined?(ActiveStorage)
Tests: test/models/source_monitor/source_favicon_test.rb
Create a separate test file to avoid conflicts with the existing source_test.rb:
Files: lib/source_monitor/favicons/discoverer.rb
Create the favicon discovery service that implements the cascade: /favicon.ico first, then HTML parsing, then Google Favicon API.
# frozen_string_literal: true
module SourceMonitor
module Favicons
class Discoverer
Result = Struct.new(:io, :filename, :content_type, :url, keyword_init: true)
attr_reader :website_url, :settings
def initialize(website_url, settings: nil)
@website_url = website_url
@settings = settings || SourceMonitor.config.favicons
end
def call
return if website_url.blank?
try_favicon_ico || try_html_link_tags || try_google_favicon_api
rescue Faraday::Error, URI::InvalidURIError, Timeout::Error
nil
end
private
def try_favicon_ico
# Build /favicon.ico URL from website_url
uri = URI.parse(website_url)
favicon_url = "#{uri.scheme}://#{uri.host}/favicon.ico"
download_favicon(favicon_url)
rescue URI::InvalidURIError
nil
end
def try_html_link_tags
# GET the HTML page, parse with Nokogiri for link[rel*=icon] and meta tags
response = http_client.get(website_url)
return unless response.status == 200
doc = Nokogiri::HTML(response.body)
candidates = extract_icon_candidates(doc)
return if candidates.empty?
# Try each candidate URL, prefer largest
candidates.each do |candidate_url|
result = download_favicon(candidate_url)
return result if result
end
nil
rescue Faraday::Error, Nokogiri::SyntaxError
nil
end
def try_google_favicon_api
# Google Favicon API: https://www.google.com/s2/favicons?domain=DOMAIN&sz=64
uri = URI.parse(website_url)
api_url = "https://www.google.com/s2/favicons?domain=#{uri.host}&sz=64"
download_favicon(api_url)
rescue URI::InvalidURIError
nil
end
def extract_icon_candidates(doc)
# ... parse link[rel] tags for icon types, meta tags for msapplication-TileImage
# Return array of absolute URLs, sorted by preference (largest first)
# Details in implementation
end
def download_favicon(url)
# ... download, validate content type and size, return Result
end
def http_client
# Build Faraday client with favicon-specific timeout
end
end
end
end
Implementation details for extract_icon_candidates:
link[rel] tags where rel contains: icon, shortcut icon, apple-touch-icon, apple-touch-icon-precomposed, mask-iconlink[rel*="icon"], link[rel="apple-touch-icon"], link[rel="apple-touch-icon-precomposed"], link[rel="mask-icon"]meta[name="msapplication-TileImage"], meta[property="og:image"] (as last resort)sizes attribute if present (prefer larger: 256x256 > 32x32 > unsized)Implementation details for download_favicon:
image/*Implementation details for http_client:
text/html, application/xhtml+xml for HTML fetch, image/* for image downloadTests: test/lib/source_monitor/favicons/discoverer_test.rb
Use WebMock to stub HTTP responses:
Files: app/jobs/source_monitor/favicon_fetch_job.rb
# frozen_string_literal: true
module SourceMonitor
class FaviconFetchJob < ApplicationJob
source_monitor_queue :fetch
discard_on ActiveJob::DeserializationError
def perform(source_id)
return unless defined?(ActiveStorage)
source = SourceMonitor::Source.find_by(id: source_id)
return unless source
return unless SourceMonitor.config.favicons.enabled?
return if source.website_url.blank?
return if source.favicon.attached?
return if within_cooldown?(source)
result = SourceMonitor::Favicons::Discoverer.new(source.website_url).call
if result
attach_favicon(source, result)
else
record_failed_attempt(source)
end
rescue StandardError => error
record_failed_attempt(source) if source
log_error(source, error)
end
private
def within_cooldown?(source)
last_attempt = source.metadata&.dig("favicon_last_attempted_at")
return false if last_attempt.blank?
cooldown_days = SourceMonitor.config.favicons.retry_cooldown_days
Time.parse(last_attempt) > cooldown_days.days.ago
rescue ArgumentError, TypeError
false
end
def attach_favicon(source, result)
blob = ActiveStorage::Blob.create_and_upload!(
io: result.io,
filename: result.filename,
content_type: result.content_type
)
source.favicon.attach(blob)
end
def record_failed_attempt(source)
metadata = (source.metadata || {}).merge(
"favicon_last_attempted_at" => Time.current.iso8601
)
source.update_column(:metadata, metadata)
rescue StandardError
nil
end
def log_error(source, error)
return unless defined?(Rails) && Rails.respond_to?(:logger) && Rails.logger
Rails.logger.warn(
"[SourceMonitor::FaviconFetchJob] Failed for source #{source&.id}: #{error.class} - #{error.message}"
)
rescue StandardError
nil
end
end
end
Tests: test/jobs/source_monitor/favicon_fetch_job_test.rb
Files: lib/source_monitor.rb
Add the Favicons module autoload block after the Images module block (around line 93):
module Favicons
autoload :Discoverer, "source_monitor/favicons/discoverer"
end
Also add the require for the settings class in configuration.rb (already handled in Task 1).
Verify the full autoload chain works by running the test suite.
Tests: Verified via Task 1-4 tests running successfully. No separate test needed.
| Action | Path |
|---|---|
| CREATE | lib/source_monitor/configuration/favicons_settings.rb |
| MODIFY | lib/source_monitor/configuration.rb |
| MODIFY | app/models/source_monitor/source.rb |
| CREATE | lib/source_monitor/favicons/discoverer.rb |
| CREATE | app/jobs/source_monitor/favicon_fetch_job.rb |
| MODIFY | lib/source_monitor.rb |
| CREATE | test/lib/source_monitor/configuration/favicons_settings_test.rb |
| CREATE | test/models/source_monitor/source_favicon_test.rb |
| CREATE | test/lib/source_monitor/favicons/discoverer_test.rb |
| CREATE | test/jobs/source_monitor/favicon_fetch_job_test.rb |
bin/rails test test/lib/source_monitor/configuration/favicons_settings_test.rb test/models/source_monitor/source_favicon_test.rb test/lib/source_monitor/favicons/discoverer_test.rb test/jobs/source_monitor/favicon_fetch_job_test.rb
bin/rubocop lib/source_monitor/configuration/favicons_settings.rb lib/source_monitor/configuration.rb app/models/source_monitor/source.rb lib/source_monitor/favicons/discoverer.rb app/jobs/source_monitor/favicon_fetch_job.rb lib/source_monitor.rb