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,68 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
# typed: strict
|
|
3
|
+
|
|
4
|
+
require 'json'
|
|
5
|
+
|
|
6
|
+
module BrandLogo
|
|
7
|
+
module Strategies
|
|
8
|
+
class Manifest
|
|
9
|
+
extend T::Sig
|
|
10
|
+
include Base
|
|
11
|
+
|
|
12
|
+
sig { override.returns(Symbol) }
|
|
13
|
+
def stage
|
|
14
|
+
:remote
|
|
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
|
+
manifest_url = page.resolve(page.document.at_css('link[rel~="manifest"]')&.[]('href'))
|
|
23
|
+
response = manifest_url && context.get(manifest_url)
|
|
24
|
+
return [] unless response
|
|
25
|
+
|
|
26
|
+
icons = parse(response)
|
|
27
|
+
icons.filter_map { |entry| build(entry, response.url) }
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
private
|
|
31
|
+
|
|
32
|
+
sig { params(response: Http::Response).returns(T::Array[T.untyped]) }
|
|
33
|
+
def parse(response)
|
|
34
|
+
data = JSON.parse(response.body)
|
|
35
|
+
icons = data['icons'] if data.is_a?(::Hash)
|
|
36
|
+
icons.is_a?(::Array) ? icons : []
|
|
37
|
+
rescue JSON::ParserError => e
|
|
38
|
+
Logging.logger.warn("Manifest: invalid JSON at #{response.url}: #{e.message}")
|
|
39
|
+
[]
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
sig { params(entry: T.untyped, manifest_url: String).returns(T.nilable(Icon)) }
|
|
43
|
+
def build(entry, manifest_url)
|
|
44
|
+
return nil unless entry.is_a?(::Hash)
|
|
45
|
+
|
|
46
|
+
url = UrlResolver.resolve(entry['src'], manifest_url) if entry['src'].is_a?(String)
|
|
47
|
+
return nil unless url
|
|
48
|
+
|
|
49
|
+
Icon.new(
|
|
50
|
+
url: url,
|
|
51
|
+
source: :manifest,
|
|
52
|
+
kind: kind(entry['purpose']),
|
|
53
|
+
format: ImageFormat.from_mime(entry['type']) || ImageFormat.from_url(url),
|
|
54
|
+
dimensions: Dimensions.parse(entry['sizes'])
|
|
55
|
+
)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
sig { params(purpose: T.untyped).returns(Symbol) }
|
|
59
|
+
def kind(purpose)
|
|
60
|
+
purposes = purpose.to_s.downcase.split
|
|
61
|
+
if purposes.include?('monochrome') then :monochrome
|
|
62
|
+
elsif purposes.include?('maskable') && !purposes.include?('any') then :maskable
|
|
63
|
+
else :icon
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
# typed: strict
|
|
3
|
+
|
|
4
|
+
module BrandLogo
|
|
5
|
+
module Strategies
|
|
6
|
+
class MetaTag
|
|
7
|
+
extend T::Sig
|
|
8
|
+
include Base
|
|
9
|
+
|
|
10
|
+
KINDS = T.let({
|
|
11
|
+
'og:image' => :social,
|
|
12
|
+
'og:image:secure_url' => :social,
|
|
13
|
+
'og:image:url' => :social,
|
|
14
|
+
'twitter:image' => :social,
|
|
15
|
+
'twitter:image:src' => :social,
|
|
16
|
+
'og:logo' => :logo,
|
|
17
|
+
'msapplication-tileimage' => :tile
|
|
18
|
+
}.freeze, T::Hash[String, Symbol])
|
|
19
|
+
|
|
20
|
+
SIDES = T.let({ 'og:image:width' => :width, 'og:image:height' => :height }.freeze, T::Hash[String, Symbol])
|
|
21
|
+
|
|
22
|
+
sig { override.returns(Symbol) }
|
|
23
|
+
def stage
|
|
24
|
+
:document
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
sig { override.params(context: Context).returns(T::Array[Icon]) }
|
|
28
|
+
def call(context)
|
|
29
|
+
page = context.page
|
|
30
|
+
page ? collect(page) : []
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
sig { params(page: Page).returns(T::Array[Icon]) }
|
|
36
|
+
def collect(page)
|
|
37
|
+
icons = T.let([], T::Array[Icon])
|
|
38
|
+
og_index = T.let(nil, T.nilable(Integer))
|
|
39
|
+
entries(page.document).each do |key, content|
|
|
40
|
+
if (side = SIDES[key])
|
|
41
|
+
icons[og_index] = with_side(T.must(icons[og_index]), side, content.to_i) if og_index
|
|
42
|
+
elsif (icon = build(page, key, content))
|
|
43
|
+
og_index = icons.size if key.start_with?('og:image')
|
|
44
|
+
icons << icon
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
icons
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
sig { params(document: Nokogiri::HTML5::Document).returns(T::Array[[String, String]]) }
|
|
51
|
+
def entries(document)
|
|
52
|
+
document.css('meta[content]').map do |meta|
|
|
53
|
+
[(meta['property'] || meta['name']).to_s.strip.downcase, meta['content'].to_s.strip]
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
sig { params(page: Page, key: String, content: String).returns(T.nilable(Icon)) }
|
|
58
|
+
def build(page, key, content)
|
|
59
|
+
kind = KINDS[key]
|
|
60
|
+
url = page.resolve(content) if kind
|
|
61
|
+
return nil unless kind && url
|
|
62
|
+
|
|
63
|
+
Icon.new(url: url, source: :meta_tag, kind: kind, format: ImageFormat.from_url(url))
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
sig { params(icon: Icon, side: Symbol, pixels: Integer).returns(Icon) }
|
|
67
|
+
def with_side(icon, side, pixels)
|
|
68
|
+
return icon unless pixels.positive?
|
|
69
|
+
|
|
70
|
+
current = icon.dimensions
|
|
71
|
+
dimensions = if side == :width
|
|
72
|
+
Dimensions.new(width: pixels, height: current.height)
|
|
73
|
+
else
|
|
74
|
+
Dimensions.new(width: current.width, height: pixels)
|
|
75
|
+
end
|
|
76
|
+
icon.with(dimensions: dimensions)
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
# typed: strict
|
|
3
|
+
|
|
4
|
+
require 'uri'
|
|
5
|
+
|
|
6
|
+
module BrandLogo
|
|
7
|
+
module UrlResolver
|
|
8
|
+
extend T::Sig
|
|
9
|
+
|
|
10
|
+
DATA_IMAGE = %r{\Adata:image/}i
|
|
11
|
+
|
|
12
|
+
sig { params(href: T.nilable(String), base: String).returns(T.nilable(String)) }
|
|
13
|
+
def self.resolve(href, base)
|
|
14
|
+
value = href.to_s.strip
|
|
15
|
+
return nil if value.empty?
|
|
16
|
+
return value if value.match?(DATA_IMAGE)
|
|
17
|
+
|
|
18
|
+
uri = join(base, value) || join(base, URI::DEFAULT_PARSER.escape(value))
|
|
19
|
+
uri.to_s if uri.is_a?(URI::HTTP) && uri.host
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
sig { params(base: String, value: String).returns(T.nilable(URI::Generic)) }
|
|
23
|
+
def self.join(base, value)
|
|
24
|
+
URI.join(base, value)
|
|
25
|
+
rescue URI::Error
|
|
26
|
+
nil
|
|
27
|
+
end
|
|
28
|
+
private_class_method :join
|
|
29
|
+
end
|
|
30
|
+
end
|
data/lib/brand_logo/version.rb
CHANGED
data/lib/brand_logo.rb
CHANGED
|
@@ -1,37 +1,39 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
# typed: strict
|
|
3
3
|
|
|
4
|
-
|
|
4
|
+
require_relative 'brand_logo/sorbet'
|
|
5
5
|
|
|
6
|
-
# Foundation
|
|
7
6
|
require_relative 'brand_logo/version'
|
|
8
7
|
require_relative 'brand_logo/errors'
|
|
9
|
-
require_relative 'brand_logo/config'
|
|
10
8
|
require_relative 'brand_logo/logging'
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
require_relative 'brand_logo/
|
|
14
|
-
require_relative 'brand_logo/
|
|
15
|
-
require_relative 'brand_logo/html_parser'
|
|
16
|
-
|
|
17
|
-
# Domain model
|
|
9
|
+
require_relative 'brand_logo/concurrency'
|
|
10
|
+
require_relative 'brand_logo/cache'
|
|
11
|
+
require_relative 'brand_logo/dimensions'
|
|
12
|
+
require_relative 'brand_logo/image_format'
|
|
18
13
|
require_relative 'brand_logo/icon'
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
require_relative 'brand_logo/
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
require_relative 'brand_logo/
|
|
25
|
-
require_relative 'brand_logo/
|
|
26
|
-
require_relative 'brand_logo/
|
|
27
|
-
|
|
28
|
-
require_relative 'brand_logo/
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
require_relative 'brand_logo/
|
|
32
|
-
require_relative 'brand_logo/
|
|
33
|
-
|
|
34
|
-
require_relative 'brand_logo/strategies/
|
|
35
|
-
|
|
36
|
-
|
|
14
|
+
require_relative 'brand_logo/config'
|
|
15
|
+
require_relative 'brand_logo/deadline'
|
|
16
|
+
require_relative 'brand_logo/domain'
|
|
17
|
+
|
|
18
|
+
require_relative 'brand_logo/http/response'
|
|
19
|
+
require_relative 'brand_logo/http/client'
|
|
20
|
+
require_relative 'brand_logo/http/url_guard'
|
|
21
|
+
require_relative 'brand_logo/http/real_client'
|
|
22
|
+
|
|
23
|
+
require_relative 'brand_logo/url_resolver'
|
|
24
|
+
require_relative 'brand_logo/image_probe'
|
|
25
|
+
require_relative 'brand_logo/page'
|
|
26
|
+
require_relative 'brand_logo/context'
|
|
27
|
+
require_relative 'brand_logo/ranker'
|
|
28
|
+
|
|
29
|
+
require_relative 'brand_logo/strategies/base'
|
|
30
|
+
require_relative 'brand_logo/strategies/link_tag'
|
|
31
|
+
require_relative 'brand_logo/strategies/json_ld'
|
|
32
|
+
require_relative 'brand_logo/strategies/meta_tag'
|
|
33
|
+
require_relative 'brand_logo/strategies/manifest'
|
|
34
|
+
require_relative 'brand_logo/strategies/browserconfig'
|
|
35
|
+
require_relative 'brand_logo/strategies/google'
|
|
36
|
+
require_relative 'brand_logo/strategies/duckduckgo'
|
|
37
|
+
|
|
38
|
+
require_relative 'brand_logo/lookup'
|
|
37
39
|
require_relative 'brand_logo/fetcher'
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: brand_logo
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version:
|
|
4
|
+
version: 2.0.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- AdVitam
|
|
@@ -30,6 +30,9 @@ dependencies:
|
|
|
30
30
|
- - ">="
|
|
31
31
|
- !ruby/object:Gem::Version
|
|
32
32
|
version: '5.2'
|
|
33
|
+
- - "<"
|
|
34
|
+
- !ruby/object:Gem::Version
|
|
35
|
+
version: '7'
|
|
33
36
|
type: :runtime
|
|
34
37
|
prerelease: false
|
|
35
38
|
version_requirements: !ruby/object:Gem::Requirement
|
|
@@ -37,6 +40,9 @@ dependencies:
|
|
|
37
40
|
- - ">="
|
|
38
41
|
- !ruby/object:Gem::Version
|
|
39
42
|
version: '5.2'
|
|
43
|
+
- - "<"
|
|
44
|
+
- !ruby/object:Gem::Version
|
|
45
|
+
version: '7'
|
|
40
46
|
- !ruby/object:Gem::Dependency
|
|
41
47
|
name: logger
|
|
42
48
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -58,6 +64,9 @@ dependencies:
|
|
|
58
64
|
- - ">="
|
|
59
65
|
- !ruby/object:Gem::Version
|
|
60
66
|
version: '1.18'
|
|
67
|
+
- - "<"
|
|
68
|
+
- !ruby/object:Gem::Version
|
|
69
|
+
version: '2'
|
|
61
70
|
type: :runtime
|
|
62
71
|
prerelease: false
|
|
63
72
|
version_requirements: !ruby/object:Gem::Requirement
|
|
@@ -65,23 +74,13 @@ dependencies:
|
|
|
65
74
|
- - ">="
|
|
66
75
|
- !ruby/object:Gem::Version
|
|
67
76
|
version: '1.18'
|
|
68
|
-
-
|
|
69
|
-
name: sorbet-runtime
|
|
70
|
-
requirement: !ruby/object:Gem::Requirement
|
|
71
|
-
requirements:
|
|
72
|
-
- - ">="
|
|
73
|
-
- !ruby/object:Gem::Version
|
|
74
|
-
version: '0.5'
|
|
75
|
-
type: :runtime
|
|
76
|
-
prerelease: false
|
|
77
|
-
version_requirements: !ruby/object:Gem::Requirement
|
|
78
|
-
requirements:
|
|
79
|
-
- - ">="
|
|
77
|
+
- - "<"
|
|
80
78
|
- !ruby/object:Gem::Version
|
|
81
|
-
version: '
|
|
79
|
+
version: '2'
|
|
82
80
|
description: 'Retrieves brand logos and icons from websites using a chain of strategies:
|
|
83
|
-
HTML favicon tags, Open Graph / Twitter meta images, PWA web app manifests,
|
|
84
|
-
|
|
81
|
+
HTML favicon tags, Open Graph / Twitter meta images, PWA web app manifests, JSON-LD
|
|
82
|
+
logos, browserconfig, then Google and DuckDuckGo fallbacks. Ranks every candidate
|
|
83
|
+
and returns the best one.'
|
|
85
84
|
executables: []
|
|
86
85
|
extensions: []
|
|
87
86
|
extra_rdoc_files: []
|
|
@@ -90,24 +89,37 @@ files:
|
|
|
90
89
|
- LICENSE.txt
|
|
91
90
|
- README.md
|
|
92
91
|
- lib/brand_logo.rb
|
|
92
|
+
- lib/brand_logo/cache.rb
|
|
93
|
+
- lib/brand_logo/concurrency.rb
|
|
93
94
|
- lib/brand_logo/config.rb
|
|
95
|
+
- lib/brand_logo/context.rb
|
|
96
|
+
- lib/brand_logo/deadline.rb
|
|
97
|
+
- lib/brand_logo/dimensions.rb
|
|
98
|
+
- lib/brand_logo/domain.rb
|
|
94
99
|
- lib/brand_logo/errors.rb
|
|
95
100
|
- lib/brand_logo/fetcher.rb
|
|
96
|
-
- lib/brand_logo/
|
|
97
|
-
- lib/brand_logo/
|
|
101
|
+
- lib/brand_logo/http/client.rb
|
|
102
|
+
- lib/brand_logo/http/real_client.rb
|
|
103
|
+
- lib/brand_logo/http/response.rb
|
|
104
|
+
- lib/brand_logo/http/url_guard.rb
|
|
98
105
|
- lib/brand_logo/icon.rb
|
|
99
|
-
- lib/brand_logo/
|
|
106
|
+
- lib/brand_logo/image_format.rb
|
|
107
|
+
- lib/brand_logo/image_probe.rb
|
|
100
108
|
- lib/brand_logo/logging.rb
|
|
101
|
-
- lib/brand_logo/
|
|
102
|
-
- lib/brand_logo/
|
|
103
|
-
- lib/brand_logo/
|
|
104
|
-
- lib/brand_logo/
|
|
105
|
-
- lib/brand_logo/
|
|
106
|
-
- lib/brand_logo/strategies/
|
|
107
|
-
- lib/brand_logo/strategies/
|
|
108
|
-
- lib/brand_logo/strategies/
|
|
109
|
-
- lib/brand_logo/strategies/
|
|
110
|
-
- lib/brand_logo/strategies/
|
|
109
|
+
- lib/brand_logo/lookup.rb
|
|
110
|
+
- lib/brand_logo/page.rb
|
|
111
|
+
- lib/brand_logo/ranker.rb
|
|
112
|
+
- lib/brand_logo/sorbet.rb
|
|
113
|
+
- lib/brand_logo/sorbet_shim.rb
|
|
114
|
+
- lib/brand_logo/strategies/base.rb
|
|
115
|
+
- lib/brand_logo/strategies/browserconfig.rb
|
|
116
|
+
- lib/brand_logo/strategies/duckduckgo.rb
|
|
117
|
+
- lib/brand_logo/strategies/google.rb
|
|
118
|
+
- lib/brand_logo/strategies/json_ld.rb
|
|
119
|
+
- lib/brand_logo/strategies/link_tag.rb
|
|
120
|
+
- lib/brand_logo/strategies/manifest.rb
|
|
121
|
+
- lib/brand_logo/strategies/meta_tag.rb
|
|
122
|
+
- lib/brand_logo/url_resolver.rb
|
|
111
123
|
- lib/brand_logo/version.rb
|
|
112
124
|
homepage: https://github.com/AdVitam/brand_logo
|
|
113
125
|
licenses:
|
|
@@ -132,7 +144,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
132
144
|
- !ruby/object:Gem::Version
|
|
133
145
|
version: '0'
|
|
134
146
|
requirements: []
|
|
135
|
-
rubygems_version:
|
|
147
|
+
rubygems_version: 4.0.20
|
|
136
148
|
specification_version: 4
|
|
137
149
|
summary: Fetch the best logo or icon for any website from its domain
|
|
138
150
|
test_files: []
|
|
@@ -1,63 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
# typed: strict
|
|
3
|
-
|
|
4
|
-
require 'sorbet-runtime'
|
|
5
|
-
require 'nokogiri'
|
|
6
|
-
|
|
7
|
-
module BrandLogo
|
|
8
|
-
# Value Object wrapping a parsed HTML document.
|
|
9
|
-
# Exposes only the subset of the Nokogiri API that strategies need,
|
|
10
|
-
# preventing strategies from depending on Nokogiri directly.
|
|
11
|
-
class ParsedDocument
|
|
12
|
-
extend T::Sig
|
|
13
|
-
|
|
14
|
-
sig { params(doc: Nokogiri::HTML::Document).void }
|
|
15
|
-
def initialize(doc)
|
|
16
|
-
@doc = T.let(doc, Nokogiri::HTML::Document)
|
|
17
|
-
end
|
|
18
|
-
|
|
19
|
-
# Returns all elements matching the CSS selector.
|
|
20
|
-
sig { params(selector: String).returns(T::Array[T.untyped]) }
|
|
21
|
-
def css(selector)
|
|
22
|
-
@doc.css(selector).to_a
|
|
23
|
-
end
|
|
24
|
-
|
|
25
|
-
# Returns the first element matching the CSS selector, or nil.
|
|
26
|
-
sig { params(selector: String).returns(T.untyped) }
|
|
27
|
-
def at(selector)
|
|
28
|
-
@doc.at(selector)
|
|
29
|
-
end
|
|
30
|
-
|
|
31
|
-
# Returns the href of the <base> tag if present.
|
|
32
|
-
sig { returns(T.nilable(String)) }
|
|
33
|
-
def base_href
|
|
34
|
-
node = @doc.at('base')
|
|
35
|
-
return nil unless node
|
|
36
|
-
|
|
37
|
-
node['href']
|
|
38
|
-
end
|
|
39
|
-
end
|
|
40
|
-
|
|
41
|
-
# Interface for parsing raw HTML into a ParsedDocument.
|
|
42
|
-
# Strategies receive this via dependency injection.
|
|
43
|
-
module HtmlParser
|
|
44
|
-
extend T::Sig
|
|
45
|
-
extend T::Helpers
|
|
46
|
-
|
|
47
|
-
interface!
|
|
48
|
-
|
|
49
|
-
sig { abstract.params(html: String).returns(ParsedDocument) }
|
|
50
|
-
def parse(html); end
|
|
51
|
-
end
|
|
52
|
-
|
|
53
|
-
# Concrete implementation using the Nokogiri gem.
|
|
54
|
-
class NokogiriParser
|
|
55
|
-
extend T::Sig
|
|
56
|
-
include HtmlParser
|
|
57
|
-
|
|
58
|
-
sig { override.params(html: String).returns(ParsedDocument) }
|
|
59
|
-
def parse(html)
|
|
60
|
-
ParsedDocument.new(Nokogiri::HTML(html))
|
|
61
|
-
end
|
|
62
|
-
end
|
|
63
|
-
end
|
|
@@ -1,59 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
# typed: strict
|
|
3
|
-
|
|
4
|
-
require 'sorbet-runtime'
|
|
5
|
-
require 'http'
|
|
6
|
-
|
|
7
|
-
module BrandLogo
|
|
8
|
-
# Interface for HTTP operations used by strategies.
|
|
9
|
-
# Decouples strategies from the HTTP gem, enabling injection of test doubles.
|
|
10
|
-
module HttpClient
|
|
11
|
-
extend T::Sig
|
|
12
|
-
extend T::Helpers
|
|
13
|
-
|
|
14
|
-
interface!
|
|
15
|
-
|
|
16
|
-
# Fetches the body of a URL. Returns nil on any failure (network, non-2xx, timeout).
|
|
17
|
-
sig { abstract.params(url: String).returns(T.nilable(String)) }
|
|
18
|
-
def get_body(url); end
|
|
19
|
-
|
|
20
|
-
# Returns true if a HEAD request to the URL succeeds (2xx). Returns false on any failure.
|
|
21
|
-
sig { abstract.params(url: String).returns(T::Boolean) }
|
|
22
|
-
def head_success?(url); end
|
|
23
|
-
end
|
|
24
|
-
|
|
25
|
-
# Concrete HTTP client using the `http` gem.
|
|
26
|
-
# Centralizes timeout and redirect configuration previously hardcoded
|
|
27
|
-
# across ScrapingStrategy, DuckduckgoStrategy, and UrlNormalizer.
|
|
28
|
-
class RealHttpClient
|
|
29
|
-
extend T::Sig
|
|
30
|
-
include HttpClient
|
|
31
|
-
|
|
32
|
-
sig { params(config: Config).void }
|
|
33
|
-
def initialize(config)
|
|
34
|
-
@timeout = T.let(config.timeout, Integer)
|
|
35
|
-
@max_hops = T.let(config.max_hops, Integer)
|
|
36
|
-
end
|
|
37
|
-
|
|
38
|
-
sig { override.params(url: String).returns(T.nilable(String)) }
|
|
39
|
-
def get_body(url)
|
|
40
|
-
response = HTTP
|
|
41
|
-
.timeout(connect: @timeout, read: @timeout, write: @timeout)
|
|
42
|
-
.follow(max_hops: @max_hops)
|
|
43
|
-
.get(url)
|
|
44
|
-
response.status.success? ? response.body.to_s : nil
|
|
45
|
-
rescue StandardError
|
|
46
|
-
nil
|
|
47
|
-
end
|
|
48
|
-
|
|
49
|
-
sig { override.params(url: String).returns(T::Boolean) }
|
|
50
|
-
def head_success?(url)
|
|
51
|
-
response = HTTP
|
|
52
|
-
.timeout(connect: @timeout, read: @timeout, write: @timeout)
|
|
53
|
-
.head(url)
|
|
54
|
-
response.status.success?
|
|
55
|
-
rescue StandardError
|
|
56
|
-
false
|
|
57
|
-
end
|
|
58
|
-
end
|
|
59
|
-
end
|
|
@@ -1,36 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
# typed: strict
|
|
3
|
-
|
|
4
|
-
require 'sorbet-runtime'
|
|
5
|
-
require 'fastimage'
|
|
6
|
-
|
|
7
|
-
module BrandLogo
|
|
8
|
-
# Interface for extracting image dimensions from a URL.
|
|
9
|
-
# Implementations are injected into strategies, enabling testing without network calls.
|
|
10
|
-
module ImageAnalyzer
|
|
11
|
-
extend T::Sig
|
|
12
|
-
extend T::Helpers
|
|
13
|
-
|
|
14
|
-
interface!
|
|
15
|
-
|
|
16
|
-
sig { abstract.params(url: String).returns(T::Hash[Symbol, T.nilable(Integer)]) }
|
|
17
|
-
def dimensions(url); end
|
|
18
|
-
end
|
|
19
|
-
|
|
20
|
-
# Concrete implementation using the FastImage gem.
|
|
21
|
-
# Avoids downloading the full image by parsing only the header bytes.
|
|
22
|
-
class FastimageAnalyzer
|
|
23
|
-
extend T::Sig
|
|
24
|
-
include ImageAnalyzer
|
|
25
|
-
|
|
26
|
-
sig { override.params(url: String).returns(T::Hash[Symbol, T.nilable(Integer)]) }
|
|
27
|
-
def dimensions(url)
|
|
28
|
-
result = FastImage.size(url)
|
|
29
|
-
return { width: nil, height: nil } unless result
|
|
30
|
-
|
|
31
|
-
{ width: result[0], height: result[1] }
|
|
32
|
-
rescue StandardError
|
|
33
|
-
{ width: nil, height: nil }
|
|
34
|
-
end
|
|
35
|
-
end
|
|
36
|
-
end
|
|
@@ -1,117 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
# typed: strict
|
|
3
|
-
|
|
4
|
-
require 'sorbet-runtime'
|
|
5
|
-
|
|
6
|
-
module BrandLogo
|
|
7
|
-
module Strategies
|
|
8
|
-
# Abstract base class implementing the Template Method pattern.
|
|
9
|
-
#
|
|
10
|
-
# Subclasses implement `fetch_all` (the customization point).
|
|
11
|
-
# `fetch` is the common algorithm: fetch_all → filter valid → select best.
|
|
12
|
-
# This eliminates duplicated selection/validation logic across strategies.
|
|
13
|
-
class BaseStrategy
|
|
14
|
-
extend T::Sig
|
|
15
|
-
extend T::Helpers
|
|
16
|
-
|
|
17
|
-
abstract!
|
|
18
|
-
|
|
19
|
-
UNKNOWN_DIMENSION_SCORE = T.let(-1, Integer)
|
|
20
|
-
|
|
21
|
-
sig { params(config: Config).void }
|
|
22
|
-
def initialize(config:)
|
|
23
|
-
@config = T.let(config, Config)
|
|
24
|
-
end
|
|
25
|
-
|
|
26
|
-
# Returns ALL icons found for the domain (unfiltered).
|
|
27
|
-
# Subclasses must implement this method.
|
|
28
|
-
sig { abstract.params(domain: String).returns(T::Array[Icon]) }
|
|
29
|
-
def fetch_all(domain); end
|
|
30
|
-
|
|
31
|
-
# Returns the best valid icon for the domain, or nil if none found.
|
|
32
|
-
# Delegates to fetch_all, filters by validity, then selects the best.
|
|
33
|
-
sig { params(domain: String).returns(T.nilable(Icon)) }
|
|
34
|
-
def fetch(domain)
|
|
35
|
-
valid_icons = fetch_all(domain).select { |icon| valid_icon?(icon) }
|
|
36
|
-
select_best_icon(valid_icons)
|
|
37
|
-
end
|
|
38
|
-
|
|
39
|
-
protected
|
|
40
|
-
|
|
41
|
-
sig { params(icon: Icon).returns(T::Boolean) }
|
|
42
|
-
def valid_icon?(icon)
|
|
43
|
-
valid_dimensions?(icon) && valid_format?(icon)
|
|
44
|
-
end
|
|
45
|
-
|
|
46
|
-
private
|
|
47
|
-
|
|
48
|
-
sig { params(icon: Icon).returns(T::Boolean) }
|
|
49
|
-
def valid_dimensions?(icon)
|
|
50
|
-
return true if svg_icon?(icon)
|
|
51
|
-
return true if missing_dimensions?(icon)
|
|
52
|
-
|
|
53
|
-
meets_minimum_dimensions?(icon) && within_maximum_dimensions?(icon)
|
|
54
|
-
end
|
|
55
|
-
|
|
56
|
-
sig { params(icon: Icon).returns(T::Boolean) }
|
|
57
|
-
def valid_format?(icon)
|
|
58
|
-
return true if icon.format != 'svg'
|
|
59
|
-
|
|
60
|
-
@config.allow_svg
|
|
61
|
-
end
|
|
62
|
-
|
|
63
|
-
sig { params(icons: T::Array[Icon]).returns(T.nilable(Icon)) }
|
|
64
|
-
def select_best_icon(icons)
|
|
65
|
-
return nil if icons.empty?
|
|
66
|
-
|
|
67
|
-
if @config.allow_svg
|
|
68
|
-
svg_icon = icons.find { |icon| icon.format == 'svg' }
|
|
69
|
-
return svg_icon if svg_icon
|
|
70
|
-
end
|
|
71
|
-
|
|
72
|
-
icons.max_by { |icon| icon_score(icon) }
|
|
73
|
-
end
|
|
74
|
-
|
|
75
|
-
sig { params(icon: Icon).returns(Integer) }
|
|
76
|
-
def icon_score(icon)
|
|
77
|
-
width = icon.dimensions[:width]
|
|
78
|
-
height = icon.dimensions[:height]
|
|
79
|
-
return UNKNOWN_DIMENSION_SCORE if width.nil? || height.nil?
|
|
80
|
-
|
|
81
|
-
width * height
|
|
82
|
-
end
|
|
83
|
-
|
|
84
|
-
sig { params(icon: Icon).returns(T::Boolean) }
|
|
85
|
-
def svg_icon?(icon)
|
|
86
|
-
@config.allow_svg && icon.format == 'svg'
|
|
87
|
-
end
|
|
88
|
-
|
|
89
|
-
sig { params(icon: Icon).returns(T::Boolean) }
|
|
90
|
-
def missing_dimensions?(icon)
|
|
91
|
-
icon.dimensions[:width].nil? || icon.dimensions[:height].nil?
|
|
92
|
-
end
|
|
93
|
-
|
|
94
|
-
sig { params(icon: Icon).returns(T::Boolean) }
|
|
95
|
-
def meets_minimum_dimensions?(icon)
|
|
96
|
-
min = @config.min_dimensions
|
|
97
|
-
width = icon.dimensions[:width]
|
|
98
|
-
height = icon.dimensions[:height]
|
|
99
|
-
return false if width.nil? || height.nil?
|
|
100
|
-
|
|
101
|
-
width >= min[:width] && height >= min[:height]
|
|
102
|
-
end
|
|
103
|
-
|
|
104
|
-
sig { params(icon: Icon).returns(T::Boolean) }
|
|
105
|
-
def within_maximum_dimensions?(icon)
|
|
106
|
-
max = @config.max_dimensions
|
|
107
|
-
return true if max.nil?
|
|
108
|
-
|
|
109
|
-
width = icon.dimensions[:width]
|
|
110
|
-
height = icon.dimensions[:height]
|
|
111
|
-
return true if width.nil? || height.nil?
|
|
112
|
-
|
|
113
|
-
width <= max[:width] && height <= max[:height]
|
|
114
|
-
end
|
|
115
|
-
end
|
|
116
|
-
end
|
|
117
|
-
end
|