brand_logo 1.0.0 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +56 -0
- data/README.md +87 -50
- data/lib/brand_logo/cache.rb +48 -0
- data/lib/brand_logo/concurrency.rb +53 -0
- data/lib/brand_logo/config.rb +95 -43
- data/lib/brand_logo/context.rb +68 -0
- data/lib/brand_logo/deadline.rb +30 -0
- data/lib/brand_logo/dimensions.rb +79 -0
- data/lib/brand_logo/domain.rb +64 -0
- data/lib/brand_logo/errors.rb +1 -3
- data/lib/brand_logo/fetcher.rb +78 -81
- data/lib/brand_logo/http/client.rb +25 -0
- data/lib/brand_logo/http/real_client.rb +110 -0
- data/lib/brand_logo/http/response.rb +8 -0
- data/lib/brand_logo/http/url_guard.rb +68 -0
- data/lib/brand_logo/icon.rb +41 -20
- data/lib/brand_logo/image_format.rb +53 -0
- data/lib/brand_logo/image_probe.rb +29 -0
- data/lib/brand_logo/logging.rb +1 -7
- data/lib/brand_logo/lookup.rb +94 -0
- data/lib/brand_logo/page.rb +37 -0
- data/lib/brand_logo/ranker.rb +115 -0
- data/lib/brand_logo/sorbet.rb +8 -0
- data/lib/brand_logo/sorbet_shim.rb +44 -0
- data/lib/brand_logo/strategies/base.rb +19 -0
- data/lib/brand_logo/strategies/browserconfig.rb +70 -0
- data/lib/brand_logo/strategies/duckduckgo.rb +21 -0
- data/lib/brand_logo/strategies/google.rb +29 -0
- data/lib/brand_logo/strategies/json_ld.rb +91 -0
- data/lib/brand_logo/strategies/link_tag.rb +58 -0
- data/lib/brand_logo/strategies/manifest.rb +68 -0
- data/lib/brand_logo/strategies/meta_tag.rb +80 -0
- data/lib/brand_logo/url_resolver.rb +30 -0
- data/lib/brand_logo/version.rb +1 -1
- data/lib/brand_logo.rb +30 -28
- metadata +42 -30
- data/lib/brand_logo/html_parser.rb +0 -63
- data/lib/brand_logo/http_client.rb +0 -59
- data/lib/brand_logo/image_analyzer.rb +0 -36
- data/lib/brand_logo/strategies/base_strategy.rb +0 -117
- data/lib/brand_logo/strategies/duckduckgo_strategy.rb +0 -45
- data/lib/brand_logo/strategies/manifest_strategy.rb +0 -137
- data/lib/brand_logo/strategies/meta_tag_strategy.rb +0 -95
- data/lib/brand_logo/strategies/scraping/default_favicon_checker.rb +0 -33
- data/lib/brand_logo/strategies/scraping/dimensions_extractor.rb +0 -42
- data/lib/brand_logo/strategies/scraping/format_extractor.rb +0 -56
- data/lib/brand_logo/strategies/scraping/icon_finder.rb +0 -79
- data/lib/brand_logo/strategies/scraping/url_normalizer.rb +0 -50
- data/lib/brand_logo/strategies/scraping_strategy.rb +0 -77
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
# typed: strict
|
|
3
|
+
|
|
4
|
+
require 'fastimage'
|
|
5
|
+
require 'stringio'
|
|
6
|
+
|
|
7
|
+
module BrandLogo
|
|
8
|
+
module ImageProbe
|
|
9
|
+
extend T::Sig
|
|
10
|
+
|
|
11
|
+
BYTES = 65_536
|
|
12
|
+
|
|
13
|
+
Result = Data.define(:format, :dimensions)
|
|
14
|
+
|
|
15
|
+
sig { params(body: String).returns(T.nilable(Result)) }
|
|
16
|
+
def self.analyze(body)
|
|
17
|
+
image = FastImage.new(StringIO.new(body), raise_on_failure: false)
|
|
18
|
+
format = ImageFormat.from_fastimage(image.type)
|
|
19
|
+
return nil unless format
|
|
20
|
+
|
|
21
|
+
width, height = image.size
|
|
22
|
+
Result.new(format: format, dimensions: Dimensions.new(width: width, height: height))
|
|
23
|
+
# FastImage parses untrusted bytes and can fail outside its own error list.
|
|
24
|
+
rescue StandardError => e
|
|
25
|
+
Logging.logger.warn("ImageProbe: unreadable image (#{e.class}: #{e.message})")
|
|
26
|
+
nil
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
data/lib/brand_logo/logging.rb
CHANGED
|
@@ -2,15 +2,9 @@
|
|
|
2
2
|
# typed: strict
|
|
3
3
|
|
|
4
4
|
require 'logger'
|
|
5
|
-
|
|
5
|
+
require_relative 'sorbet'
|
|
6
6
|
|
|
7
7
|
module BrandLogo
|
|
8
|
-
# Centralized logging for the gem.
|
|
9
|
-
# Replaces the DebugLogger module's debug boolean pattern.
|
|
10
|
-
#
|
|
11
|
-
# Usage:
|
|
12
|
-
# BrandLogo::Logging.logger.level = Logger::DEBUG # enable verbose output
|
|
13
|
-
# BrandLogo::Logging.logger = MyCustomLogger.new # inject custom logger
|
|
14
8
|
module Logging
|
|
15
9
|
extend T::Sig
|
|
16
10
|
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
# typed: strict
|
|
3
|
+
|
|
4
|
+
module BrandLogo
|
|
5
|
+
class Lookup
|
|
6
|
+
extend T::Sig
|
|
7
|
+
|
|
8
|
+
STAGES = T.let(%i[document remote external].freeze, T::Array[Symbol])
|
|
9
|
+
|
|
10
|
+
sig { params(context: Context, strategies: T::Array[Strategies::Base], ranker: Ranker).void }
|
|
11
|
+
def initialize(context:, strategies:, ranker:)
|
|
12
|
+
@context = context
|
|
13
|
+
@strategies = strategies
|
|
14
|
+
@ranker = ranker
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
sig { returns(T.nilable(Icon)) }
|
|
18
|
+
def best
|
|
19
|
+
icons = T.let([], T::Array[Icon])
|
|
20
|
+
STAGES.each do |stage|
|
|
21
|
+
break if @context.deadline.expired?
|
|
22
|
+
next if skip?(stage, icons)
|
|
23
|
+
|
|
24
|
+
icons = run(stage, icons)
|
|
25
|
+
winner = icons.first
|
|
26
|
+
return winner if winner && @ranker.good_enough?(winner)
|
|
27
|
+
end
|
|
28
|
+
icons.first
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
sig { returns(T::Array[Icon]) }
|
|
32
|
+
def all
|
|
33
|
+
STAGES.reduce(T.let([], T::Array[Icon])) do |icons, stage|
|
|
34
|
+
@context.deadline.expired? ? icons : run(stage, icons)
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
sig { returns(T::Boolean) }
|
|
39
|
+
def timed_out?
|
|
40
|
+
@context.deadline.expired?
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
private
|
|
44
|
+
|
|
45
|
+
sig { params(stage: Symbol, icons: T::Array[Icon]).returns(T::Boolean) }
|
|
46
|
+
def skip?(stage, icons)
|
|
47
|
+
stage == :external && icons.any? { |icon| @ranker.usable?(icon) }
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
sig { params(stage: Symbol, previous: T::Array[Icon]).returns(T::Array[Icon]) }
|
|
51
|
+
def run(stage, previous)
|
|
52
|
+
seen = previous.to_set(&:url)
|
|
53
|
+
candidates = collect(stage).uniq(&:url).reject { |icon| seen.include?(icon.url) }
|
|
54
|
+
BrandLogo::Logging.logger.debug("[#{@context.domain}] #{stage}: #{candidates.size} new candidate(s)")
|
|
55
|
+
@ranker.rank(previous + parallel(candidates) { |icon| measure(icon) }.compact)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
sig { params(stage: Symbol).returns(T::Array[Icon]) }
|
|
59
|
+
def collect(stage)
|
|
60
|
+
strategies = @strategies.select { |strategy| strategy.stage == stage }
|
|
61
|
+
parallel(strategies) { |strategy| strategy.call(@context) }.flatten
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
sig { params(icon: Icon).returns(T.nilable(Icon)) }
|
|
65
|
+
def measure(icon)
|
|
66
|
+
result = @context.probe(icon.url)
|
|
67
|
+
return nil unless result
|
|
68
|
+
|
|
69
|
+
icon.with(format: result.format, dimensions: measured_dimensions(icon, result))
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# FastImage reads only the first entry of an .ico, which is often smaller than the declared largest one.
|
|
73
|
+
sig { params(icon: Icon, result: ImageProbe::Result).returns(Dimensions) }
|
|
74
|
+
def measured_dimensions(icon, result)
|
|
75
|
+
measured = result.dimensions
|
|
76
|
+
return icon.dimensions unless measured.known?
|
|
77
|
+
return [measured, icon.dimensions].max_by(&:area) || measured if result.format == :ico
|
|
78
|
+
|
|
79
|
+
measured
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
sig do
|
|
83
|
+
type_parameters(:I, :O)
|
|
84
|
+
.params(
|
|
85
|
+
items: T::Array[T.type_parameter(:I)],
|
|
86
|
+
block: T.proc.params(item: T.type_parameter(:I)).returns(T.type_parameter(:O))
|
|
87
|
+
)
|
|
88
|
+
.returns(T::Array[T.type_parameter(:O)])
|
|
89
|
+
end
|
|
90
|
+
def parallel(items, &block)
|
|
91
|
+
Concurrency.map(items, size: @context.config.concurrency, &block)
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
# typed: strict
|
|
3
|
+
|
|
4
|
+
require 'nokogiri'
|
|
5
|
+
|
|
6
|
+
module BrandLogo
|
|
7
|
+
Page = Data.define(:url, :document, :base_url) do
|
|
8
|
+
extend T::Sig
|
|
9
|
+
|
|
10
|
+
sig { params(context: Context).returns(T.nilable(Page)) }
|
|
11
|
+
def self.fetch(context)
|
|
12
|
+
domain = context.domain
|
|
13
|
+
["https://#{domain}", "https://www.#{domain}", "http://#{domain}"].each do |candidate|
|
|
14
|
+
response = context.get(candidate)
|
|
15
|
+
return build(response) if response
|
|
16
|
+
end
|
|
17
|
+
nil
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
sig { params(response: Http::Response).returns(T.nilable(Page)) }
|
|
21
|
+
def self.build(response)
|
|
22
|
+
document = Nokogiri::HTML5(response.body)
|
|
23
|
+
declared = document.at_css('base[href]')&.[]('href')
|
|
24
|
+
base_url = UrlResolver.resolve(declared, response.url) || response.url
|
|
25
|
+
new(url: response.url, document: document, base_url: base_url)
|
|
26
|
+
rescue ArgumentError => e
|
|
27
|
+
Logging.logger.warn("Page: cannot parse #{response.url}: #{e.message}")
|
|
28
|
+
nil
|
|
29
|
+
end
|
|
30
|
+
private_class_method :build
|
|
31
|
+
|
|
32
|
+
sig { params(href: T.nilable(String)).returns(T.nilable(String)) }
|
|
33
|
+
def resolve(href)
|
|
34
|
+
UrlResolver.resolve(href, base_url)
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
# typed: strict
|
|
3
|
+
|
|
4
|
+
module BrandLogo
|
|
5
|
+
class Ranker
|
|
6
|
+
extend T::Sig
|
|
7
|
+
|
|
8
|
+
KIND_TIERS = T.let({
|
|
9
|
+
mask: 3, monochrome: 3, social: 2, tile: 1
|
|
10
|
+
}.freeze, T::Hash[Symbol, Integer])
|
|
11
|
+
UNUSABLE_TIER = 2
|
|
12
|
+
SCHEMES = T.let({
|
|
13
|
+
dark: /prefers-color-scheme\s*:\s*dark/i, light: /prefers-color-scheme\s*:\s*light/i
|
|
14
|
+
}.freeze, T::Hash[Symbol, Regexp])
|
|
15
|
+
DEFAULT_GOOD_SIZE = 128
|
|
16
|
+
MAX_ASPECT_RATIO = 1.2
|
|
17
|
+
|
|
18
|
+
sig { params(config: Config).void }
|
|
19
|
+
def initialize(config)
|
|
20
|
+
@config = config
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
sig { params(icons: T::Array[Icon]).returns(T::Array[Icon]) }
|
|
24
|
+
def rank(icons)
|
|
25
|
+
icons.select { |icon| valid?(icon) }
|
|
26
|
+
.each_with_index
|
|
27
|
+
.sort_by { |icon, index| [*penalty(icon), index] }
|
|
28
|
+
.map(&:first)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
sig { params(icon: Icon).returns(T::Boolean) }
|
|
32
|
+
def usable?(icon)
|
|
33
|
+
valid?(icon) && KIND_TIERS.fetch(icon.kind, 0) < UNUSABLE_TIER
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
sig { params(icon: Icon).returns(T::Boolean) }
|
|
37
|
+
def good_enough?(icon)
|
|
38
|
+
return false unless usable?(icon)
|
|
39
|
+
return false if @config.prefer == :square && non_square?(icon)
|
|
40
|
+
|
|
41
|
+
icon.svg? || icon.dimensions.side >= (@config.target_size || DEFAULT_GOOD_SIZE)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
private
|
|
45
|
+
|
|
46
|
+
sig { params(icon: Icon).returns(T::Boolean) }
|
|
47
|
+
def valid?(icon)
|
|
48
|
+
return false if icon.format.nil?
|
|
49
|
+
return @config.allow_svg if icon.svg?
|
|
50
|
+
|
|
51
|
+
within_limits?(icon.dimensions)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
sig { params(dims: Dimensions).returns(T::Boolean) }
|
|
55
|
+
def within_limits?(dims)
|
|
56
|
+
min = @config.min_size
|
|
57
|
+
max = @config.max_size
|
|
58
|
+
(min.nil? || dims.at_least?(min)) && (max.nil? || dims.at_most?(max))
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
sig { params(icon: Icon).returns(T::Array[Numeric]) }
|
|
62
|
+
def penalty(icon)
|
|
63
|
+
[KIND_TIERS.fetch(icon.kind, 0), scheme_penalty(icon), *preference_penalty(icon), *size_penalty(icon)]
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
sig { params(icon: Icon).returns(Integer) }
|
|
67
|
+
def scheme_penalty(icon)
|
|
68
|
+
wanted = @config.color_scheme
|
|
69
|
+
return media_mentions?(icon, :dark) ? 1 : 0 unless wanted
|
|
70
|
+
|
|
71
|
+
opposite = wanted == :dark ? :light : :dark
|
|
72
|
+
return 0 if media_mentions?(icon, wanted)
|
|
73
|
+
|
|
74
|
+
media_mentions?(icon, opposite) ? 2 : 1
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
sig { params(icon: Icon, scheme: Symbol).returns(T::Boolean) }
|
|
78
|
+
def media_mentions?(icon, scheme)
|
|
79
|
+
SCHEMES.fetch(scheme).match?(icon.media.to_s)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
sig { params(icon: Icon).returns(T::Array[Integer]) }
|
|
83
|
+
def preference_penalty(icon)
|
|
84
|
+
case @config.prefer
|
|
85
|
+
when :svg then [icon.svg? ? 0 : 1]
|
|
86
|
+
when :square then [non_square?(icon) ? 1 : 0, icon.svg? ? 0 : 1]
|
|
87
|
+
else []
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
sig { params(icon: Icon).returns(T::Boolean) }
|
|
92
|
+
def non_square?(icon)
|
|
93
|
+
w, h = icon.dimensions.to_pair
|
|
94
|
+
return false unless w && h
|
|
95
|
+
|
|
96
|
+
[w, h].max.fdiv([w, h].min) > MAX_ASPECT_RATIO
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
sig { params(icon: Icon).returns(T::Array[Numeric]) }
|
|
100
|
+
def size_penalty(icon)
|
|
101
|
+
target = @config.target_size
|
|
102
|
+
return [-area(icon)] unless target
|
|
103
|
+
|
|
104
|
+
side = icon.svg? ? target : icon.dimensions.side
|
|
105
|
+
return [0, target / 2.0, -area(icon)] if side.zero?
|
|
106
|
+
|
|
107
|
+
[side < target ? 1 : 0, (side - target).abs, -area(icon)]
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
sig { params(icon: Icon).returns(Numeric) }
|
|
111
|
+
def area(icon)
|
|
112
|
+
icon.svg? ? Float::INFINITY : icon.dimensions.area
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
# typed: ignore
|
|
3
|
+
|
|
4
|
+
# No-op stand-in for sorbet-runtime so host apps are not forced to depend on it.
|
|
5
|
+
# Signatures are only enforced statically (`srb tc`); `sig` blocks are never evaluated.
|
|
6
|
+
module T
|
|
7
|
+
module Sig
|
|
8
|
+
def sig(*) = nil
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
module Helpers
|
|
12
|
+
def abstract! = nil
|
|
13
|
+
def interface! = nil
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
module Boolean; end
|
|
17
|
+
|
|
18
|
+
module Generic
|
|
19
|
+
def [](*) = self
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
module Array; extend Generic; end
|
|
23
|
+
module Hash; extend Generic; end
|
|
24
|
+
module Range; extend Generic; end
|
|
25
|
+
|
|
26
|
+
class << self
|
|
27
|
+
def let(value, _type) = value
|
|
28
|
+
def cast(value, _type) = value
|
|
29
|
+
|
|
30
|
+
def must(value)
|
|
31
|
+
raise TypeError, 'Passed `nil` into T.must' if value.nil?
|
|
32
|
+
|
|
33
|
+
value
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def nilable(*) = nil
|
|
37
|
+
def any(*) = nil
|
|
38
|
+
def untyped = nil
|
|
39
|
+
def class_of(*) = nil
|
|
40
|
+
def noreturn = nil
|
|
41
|
+
def type_parameter(*) = nil
|
|
42
|
+
def proc = nil
|
|
43
|
+
end
|
|
44
|
+
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
# typed: strict
|
|
3
|
+
|
|
4
|
+
module BrandLogo
|
|
5
|
+
module Strategies
|
|
6
|
+
module Base
|
|
7
|
+
extend T::Sig
|
|
8
|
+
extend T::Helpers
|
|
9
|
+
|
|
10
|
+
interface!
|
|
11
|
+
|
|
12
|
+
sig { abstract.returns(Symbol) }
|
|
13
|
+
def stage; end
|
|
14
|
+
|
|
15
|
+
sig { abstract.params(context: Context).returns(T::Array[Icon]) }
|
|
16
|
+
def call(context); end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
# typed: strict
|
|
3
|
+
|
|
4
|
+
module BrandLogo
|
|
5
|
+
module Strategies
|
|
6
|
+
class Browserconfig
|
|
7
|
+
extend T::Sig
|
|
8
|
+
include Base
|
|
9
|
+
|
|
10
|
+
TILES = T.let({
|
|
11
|
+
'square70x70logo' => [70, 70],
|
|
12
|
+
'square150x150logo' => [150, 150],
|
|
13
|
+
'square310x310logo' => [310, 310],
|
|
14
|
+
'wide310x150logo' => [310, 150]
|
|
15
|
+
}.freeze, T::Hash[String, [Integer, Integer]])
|
|
16
|
+
|
|
17
|
+
sig { override.returns(Symbol) }
|
|
18
|
+
def stage
|
|
19
|
+
:remote
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
sig { override.params(context: Context).returns(T::Array[Icon]) }
|
|
23
|
+
def call(context)
|
|
24
|
+
response = fetch_config(context)
|
|
25
|
+
document = response && parse(response)
|
|
26
|
+
return [] unless response && document
|
|
27
|
+
|
|
28
|
+
document.xpath('//*').filter_map { |node| build(node, response.url) }
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
private
|
|
32
|
+
|
|
33
|
+
sig { params(context: Context).returns(T.nilable(Http::Response)) }
|
|
34
|
+
def fetch_config(context)
|
|
35
|
+
page = context.page
|
|
36
|
+
return nil unless page
|
|
37
|
+
|
|
38
|
+
declared = page.document.at_css('meta[name="msapplication-config"]')&.[]('content').to_s.strip
|
|
39
|
+
url = page.resolve(declared) unless declared.casecmp?('none')
|
|
40
|
+
url && context.get(url)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
sig { params(response: Http::Response).returns(T.nilable(Nokogiri::XML::Document)) }
|
|
44
|
+
def parse(response)
|
|
45
|
+
document = Nokogiri::XML(response.body, &:strict)
|
|
46
|
+
document.remove_namespaces!
|
|
47
|
+
document
|
|
48
|
+
rescue Nokogiri::XML::SyntaxError => e
|
|
49
|
+
Logging.logger.warn("Browserconfig: invalid XML at #{response.url}: #{e.message}")
|
|
50
|
+
nil
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
sig { params(node: Nokogiri::XML::Node, config_url: String).returns(T.nilable(Icon)) }
|
|
54
|
+
def build(node, config_url)
|
|
55
|
+
size = TILES[node.name.downcase]
|
|
56
|
+
url = UrlResolver.resolve(node['src'], config_url) if size
|
|
57
|
+
return nil unless size && url
|
|
58
|
+
|
|
59
|
+
width, height = size
|
|
60
|
+
Icon.new(
|
|
61
|
+
url: url,
|
|
62
|
+
source: :browserconfig,
|
|
63
|
+
kind: :tile,
|
|
64
|
+
format: ImageFormat.from_url(url),
|
|
65
|
+
dimensions: Dimensions.new(width: width, height: height)
|
|
66
|
+
)
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
# typed: strict
|
|
3
|
+
|
|
4
|
+
module BrandLogo
|
|
5
|
+
module Strategies
|
|
6
|
+
class Duckduckgo
|
|
7
|
+
extend T::Sig
|
|
8
|
+
include Base
|
|
9
|
+
|
|
10
|
+
sig { override.returns(Symbol) }
|
|
11
|
+
def stage
|
|
12
|
+
:external
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
sig { override.params(context: Context).returns(T::Array[Icon]) }
|
|
16
|
+
def call(context)
|
|
17
|
+
[Icon.new(url: "https://icons.duckduckgo.com/ip3/#{context.domain}.ico", source: :duckduckgo)]
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
# typed: strict
|
|
3
|
+
|
|
4
|
+
require 'cgi'
|
|
5
|
+
|
|
6
|
+
module BrandLogo
|
|
7
|
+
module Strategies
|
|
8
|
+
# Google's favicon service answers 404 for unknown domains, which the probe drops.
|
|
9
|
+
class Google
|
|
10
|
+
extend T::Sig
|
|
11
|
+
include Base
|
|
12
|
+
|
|
13
|
+
SIZE = 256
|
|
14
|
+
|
|
15
|
+
sig { override.returns(Symbol) }
|
|
16
|
+
def stage
|
|
17
|
+
:external
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
sig { override.params(context: Context).returns(T::Array[Icon]) }
|
|
21
|
+
def call(context)
|
|
22
|
+
[Icon.new(
|
|
23
|
+
url: "https://www.google.com/s2/favicons?domain=#{CGI.escape(context.domain)}&sz=#{SIZE}",
|
|
24
|
+
source: :google
|
|
25
|
+
)]
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
# typed: strict
|
|
3
|
+
|
|
4
|
+
require 'json'
|
|
5
|
+
|
|
6
|
+
module BrandLogo
|
|
7
|
+
module Strategies
|
|
8
|
+
class JsonLd
|
|
9
|
+
extend T::Sig
|
|
10
|
+
include Base
|
|
11
|
+
|
|
12
|
+
ORGANIZATION_TYPES = T.let(%w[Organization Corporation NGO LocalBusiness Brand].freeze, T::Array[String])
|
|
13
|
+
ORGANIZATION_SUFFIXES = T.let(%w[Organization Business Store Brand].freeze, T::Array[String])
|
|
14
|
+
OWNER_KEYS = T.let(%w[publisher brand].freeze, T::Array[String])
|
|
15
|
+
|
|
16
|
+
sig { override.returns(Symbol) }
|
|
17
|
+
def stage
|
|
18
|
+
:document
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
sig { override.params(context: Context).returns(T::Array[Icon]) }
|
|
22
|
+
def call(context)
|
|
23
|
+
page = context.page
|
|
24
|
+
return [] unless page
|
|
25
|
+
|
|
26
|
+
logos = []
|
|
27
|
+
page.document.css('script[type="application/ld+json"]').each do |script|
|
|
28
|
+
collect(JSON.parse(script.text), false, logos)
|
|
29
|
+
rescue JSON::ParserError => e
|
|
30
|
+
Logging.logger.warn("JsonLd: invalid JSON at #{page.url}: #{e.message}")
|
|
31
|
+
end
|
|
32
|
+
logos.filter_map { |logo| build(page, logo) }
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
sig { params(node: T.untyped, owner: T::Boolean, logos: T::Array[T.untyped]).void }
|
|
38
|
+
def collect(node, owner, logos)
|
|
39
|
+
case node
|
|
40
|
+
when ::Array
|
|
41
|
+
node.each { |child| collect(child, owner, logos) }
|
|
42
|
+
when ::Hash
|
|
43
|
+
logos.concat(declared_logos(node)) if owner || organization?(node['@type'])
|
|
44
|
+
node.each { |key, child| collect(child, OWNER_KEYS.include?(key), logos) }
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
sig { params(node: T::Hash[String, T.untyped]).returns(T::Array[T.untyped]) }
|
|
49
|
+
def declared_logos(node)
|
|
50
|
+
declared = node['logo']
|
|
51
|
+
declared.is_a?(::Array) ? declared : [declared].compact
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
sig { params(types: T.untyped).returns(T::Boolean) }
|
|
55
|
+
def organization?(types)
|
|
56
|
+
Array(types).any? do |type|
|
|
57
|
+
name = type.to_s.split(%r{[/:#]}).last.to_s
|
|
58
|
+
ORGANIZATION_TYPES.include?(name) || name.end_with?(*ORGANIZATION_SUFFIXES)
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
sig { params(page: Page, logo: T.untyped).returns(T.nilable(Icon)) }
|
|
63
|
+
def build(page, logo)
|
|
64
|
+
declared = logo.is_a?(::Hash) ? (logo['url'] || logo['contentUrl']) : logo
|
|
65
|
+
url = page.resolve(declared) if declared.is_a?(String)
|
|
66
|
+
return nil unless url
|
|
67
|
+
|
|
68
|
+
Icon.new(url: url, source: :json_ld, kind: :logo, format: format_of(logo, url), dimensions: dimensions(logo))
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
sig { params(logo: T.untyped, url: String).returns(T.nilable(Symbol)) }
|
|
72
|
+
def format_of(logo, url)
|
|
73
|
+
mime = logo['encodingFormat'] if logo.is_a?(::Hash)
|
|
74
|
+
ImageFormat.from_mime(mime) || ImageFormat.from_url(url)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
sig { params(logo: T.untyped).returns(Dimensions) }
|
|
78
|
+
def dimensions(logo)
|
|
79
|
+
return Dimensions.new unless logo.is_a?(::Hash)
|
|
80
|
+
|
|
81
|
+
Dimensions.new(width: pixels(logo['width']), height: pixels(logo['height']))
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
sig { params(value: T.untyped).returns(T.nilable(Integer)) }
|
|
85
|
+
def pixels(value)
|
|
86
|
+
number = value.is_a?(Numeric) ? value.to_i : value.to_s[/\A\d+/]&.to_i
|
|
87
|
+
number if number&.positive?
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
# typed: strict
|
|
3
|
+
|
|
4
|
+
module BrandLogo
|
|
5
|
+
module Strategies
|
|
6
|
+
class LinkTag
|
|
7
|
+
extend T::Sig
|
|
8
|
+
include Base
|
|
9
|
+
|
|
10
|
+
APPLE_TOUCH = T.let(%w[apple-touch-icon apple-touch-icon-precomposed].freeze, T::Array[String])
|
|
11
|
+
|
|
12
|
+
sig { override.returns(Symbol) }
|
|
13
|
+
def stage
|
|
14
|
+
:document
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
sig { override.params(context: Context).returns(T::Array[Icon]) }
|
|
18
|
+
def call(context)
|
|
19
|
+
page = context.page
|
|
20
|
+
return [] unless page
|
|
21
|
+
|
|
22
|
+
page.document.css('link[rel]').filter_map { |link| build(page, link) } << favicon(page)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
private
|
|
26
|
+
|
|
27
|
+
sig { params(page: Page, link: Nokogiri::XML::Element).returns(T.nilable(Icon)) }
|
|
28
|
+
def build(page, link)
|
|
29
|
+
kind = kind_of(link['rel'].to_s.downcase.split)
|
|
30
|
+
url = page.resolve(link['href'])
|
|
31
|
+
return nil unless kind && url
|
|
32
|
+
|
|
33
|
+
Icon.new(
|
|
34
|
+
url: url,
|
|
35
|
+
source: :link_tag,
|
|
36
|
+
kind: kind,
|
|
37
|
+
format: ImageFormat.from_mime(link['type']) || ImageFormat.from_url(url),
|
|
38
|
+
dimensions: Dimensions.parse(link['sizes']),
|
|
39
|
+
media: link['media']
|
|
40
|
+
)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
sig { params(tokens: T::Array[String]).returns(T.nilable(Symbol)) }
|
|
44
|
+
def kind_of(tokens)
|
|
45
|
+
if tokens.include?('mask-icon') then :mask
|
|
46
|
+
elsif tokens.intersect?(APPLE_TOUCH) then :apple_touch
|
|
47
|
+
elsif tokens.include?('icon') then :icon
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
sig { params(page: Page).returns(Icon) }
|
|
52
|
+
def favicon(page)
|
|
53
|
+
url = T.must(UrlResolver.resolve('/favicon.ico', page.url))
|
|
54
|
+
Icon.new(url: url, source: :link_tag, format: :ico)
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|