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
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 8ca5db19ef68c02172defd8e04ba4a228c5819c32e3f2aa8dd10de412b4889f1
|
|
4
|
+
data.tar.gz: 6a1bf3a2bda344977f005a0c12ae07c884fa24bc19d5bdabc650ccb57f2e9806
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: ee0e662864b0ede5a0a78b538109468efc96db273c41bb37ccb20c36f808e3869258e5e29792c8dfcb5a7410fc3c4fe45ffa8e9f6a3453197e421abc80a08173
|
|
7
|
+
data.tar.gz: 74c9c3d4fe4102fdc86bd2070129dc93b9f7b09da79677278c1ea0b3e4bc1cfdc6779195f4b800e0ea9d410c71b0f2d72f89c3ab59354d40b34385bac48557f3
|
data/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,62 @@
|
|
|
3
3
|
All notable changes to this project will be documented here.
|
|
4
4
|
Format: [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
|
5
5
|
|
|
6
|
+
## [2.0.0] - 2026-10-02
|
|
7
|
+
|
|
8
|
+
Rewrite focused on picking the right icon, speed and safety. Breaking changes: see *Upgrading*.
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
|
|
12
|
+
- `JsonLd` strategy: schema.org `Organization` / `Brand` logos (including `@graph` and `ImageObject`).
|
|
13
|
+
- `Browserconfig` strategy (`msapplication-config`) and `msapplication-TileImage` / `og:logo` meta tags.
|
|
14
|
+
- `Google` favicon service fallback, before DuckDuckGo; both can be disabled with `external_fallbacks: []`.
|
|
15
|
+
- `Fetcher#fetch!`, `Fetcher#fetch_many` (concurrent), URL input (`fetch('https://github.com/about')`).
|
|
16
|
+
- Caching: in-memory by default, or any `read`/`write(expires_in:)` store such as `Rails.cache`; misses use `negative_cache_ttl`.
|
|
17
|
+
- Ranking options: `prefer` (`:square`, `:largest`, `:svg`), `target_size`, `color_scheme` (`media` attribute).
|
|
18
|
+
- `deadline` (total time per lookup), `user_agent`, `max_body_bytes`, `concurrency`, `block_private_ips`.
|
|
19
|
+
- `Icon#source`, `#kind`, `#media`, `#width`, `#height`, `#to_h`, `Icon.from_h`.
|
|
20
|
+
|
|
21
|
+
### Changed
|
|
22
|
+
|
|
23
|
+
- `fetch` returns the best icon across strategies (stopping early once one is good enough) instead of the first strategy's result.
|
|
24
|
+
- Every candidate is probed: dead links, HTML soft-404s and non-images are dropped; the format comes from the image bytes.
|
|
25
|
+
- The homepage is fetched once per lookup (was up to 7 times); probes are deduplicated, run in parallel and read 64 KB at most.
|
|
26
|
+
- Safari mask icons, monochrome icons and social banners rank last; PWA manifest icons beat `og:image`.
|
|
27
|
+
- Redirects are followed for every request and the final URL (or `<base href>`) is used to resolve relative links.
|
|
28
|
+
- Requests send a browser-like `User-Agent`; `timeout` is now a total per request (default 5 s).
|
|
29
|
+
- `sizes="16x16 32x32"`, uppercase `X` and query strings (`/icon.png?v=2`) are understood.
|
|
30
|
+
- Domain validation accepts URLs and mixed case, rejects malformed labels (`-a.com`, `..com`).
|
|
31
|
+
- `sorbet-runtime` is no longer a runtime dependency; `http` and `nokogiri` versions are bounded.
|
|
32
|
+
|
|
33
|
+
### Security
|
|
34
|
+
|
|
35
|
+
- SSRF protection (`block_private_ips`, on by default): private, loopback and link-local addresses are refused at every redirect; only `http`/`https` URLs are fetched.
|
|
36
|
+
|
|
37
|
+
### Removed
|
|
38
|
+
|
|
39
|
+
- `ScrapingStrategy` (now `LinkTag`), `MetaTagStrategy`, `ManifestStrategy`, `DuckduckgoStrategy` (now `Strategies::MetaTag`, `Manifest`, `Duckduckgo`).
|
|
40
|
+
- `HttpClient`, `HtmlParser`, `ImageAnalyzer` interfaces: inject a `BrandLogo::Http::Client` instead.
|
|
41
|
+
- `FetchError`, `ParseError`.
|
|
42
|
+
|
|
43
|
+
### Upgrading from 1.x
|
|
44
|
+
|
|
45
|
+
| 1.x | 2.0 |
|
|
46
|
+
|---|---|
|
|
47
|
+
| `fetch(domain)` raises `NoIconFoundError` | `fetch` returns `nil`; use `fetch!` to raise |
|
|
48
|
+
| `icon.dimensions[:width]` | `icon.width` (or `icon.dimensions.width`) |
|
|
49
|
+
| `icon.format == 'svg'` | `icon.format == :svg` |
|
|
50
|
+
| `Config.new(min_dimensions: { width: 32, height: 32 })` | `Config.new(min_size: 32)` |
|
|
51
|
+
| `max_dimensions:` | `max_size:` |
|
|
52
|
+
| `Fetcher.new(strategies: [ScrapingStrategy.new(config:, http_client:, …)])` | `Fetcher.new(strategies: [Strategies::LinkTag.new])` |
|
|
53
|
+
| custom strategy: subclass `BaseStrategy`, implement `fetch_all(domain)` | `include Strategies::Base`, implement `stage` and `call(context)` |
|
|
54
|
+
|
|
55
|
+
## [1.0.1] - 2026-07-23
|
|
56
|
+
|
|
57
|
+
### Changed
|
|
58
|
+
|
|
59
|
+
- Publish to RubyGems via trusted publishing (OIDC) instead of a long-lived API key
|
|
60
|
+
- Bump simplecov to 1.0.0, migrate `add_filter` to `skip`
|
|
61
|
+
|
|
6
62
|
## [1.0.0] - 2026-04-15
|
|
7
63
|
|
|
8
64
|
First public release under the name `brand_logo`.
|
data/README.md
CHANGED
|
@@ -3,11 +3,24 @@
|
|
|
3
3
|
[](https://badge.fury.io/rb/brand_logo)
|
|
4
4
|
[](https://github.com/AdVitam/brand_logo/actions/workflows/main.yml)
|
|
5
5
|
|
|
6
|
-
|
|
6
|
+
**Give it a domain, get back the best logo that site has to offer.**
|
|
7
7
|
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
8
|
+
```ruby
|
|
9
|
+
BrandLogo::Fetcher.new.fetch('github.com').url # => "https://github.com/fluidicon.png"
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
Perfect for company directories, CRM enrichment, link previews or any list of brands that
|
|
13
|
+
deserves real logos instead of blurry 16 px favicons.
|
|
14
|
+
|
|
15
|
+
- **The right icon, not the first one** — favicon links, `apple-touch-icon`, schema.org JSON-LD
|
|
16
|
+
logos, Open Graph tags, PWA manifest and `browserconfig.xml` are compared by their *real*
|
|
17
|
+
size and format. Square, crisp and SVG wins; Safari mask icons and social banners lose.
|
|
18
|
+
- **Fast** — one homepage request, image probes run in parallel and read 64 KB at most,
|
|
19
|
+
the lookup stops as soon as a good icon is found, and results are cached (`Rails.cache` ready).
|
|
20
|
+
- **Never empty-handed** — Google and DuckDuckGo favicon services as a last resort.
|
|
21
|
+
- **Safe on untrusted input** — SSRF protection on every redirect, bounded timeouts and body sizes.
|
|
22
|
+
- **Tunable** — prefer square, largest or SVG, target a size, pick dark-mode icons.
|
|
23
|
+
- **Light** — no `sorbet-runtime` dependency, thread-safe, batch lookups with `fetch_many`.
|
|
11
24
|
|
|
12
25
|
## Installation
|
|
13
26
|
|
|
@@ -21,78 +34,95 @@ gem 'brand_logo'
|
|
|
21
34
|
require 'brand_logo'
|
|
22
35
|
|
|
23
36
|
fetcher = BrandLogo::Fetcher.new
|
|
24
|
-
icon = fetcher.fetch('github.com')
|
|
25
37
|
|
|
26
|
-
icon.
|
|
27
|
-
icon.
|
|
28
|
-
icon.
|
|
38
|
+
icon = fetcher.fetch('github.com') # => BrandLogo::Icon or nil
|
|
39
|
+
icon.url # => "https://github.com/fluidicon.png"
|
|
40
|
+
icon.format # => :png
|
|
41
|
+
icon.width # => 512
|
|
42
|
+
icon.source # => :link_tag
|
|
43
|
+
icon.kind # => :icon
|
|
44
|
+
icon.to_h # => { url:, format:, width:, height:, source:, kind:, media: }
|
|
45
|
+
|
|
46
|
+
fetcher.fetch!('https://github.com/about') # URLs are accepted; raises NoIconFoundError when nothing is found
|
|
47
|
+
fetcher.fetch_all('github.com') # every valid icon, best first
|
|
48
|
+
fetcher.fetch_many(%w[github.com ruby-lang.org]) # => { 'github.com' => Icon, 'ruby-lang.org' => Icon }
|
|
29
49
|
```
|
|
30
50
|
|
|
31
|
-
|
|
51
|
+
A `Fetcher` is thread-safe and meant to be reused (it holds the cache).
|
|
32
52
|
|
|
33
|
-
|
|
34
|
-
icons = fetcher.fetch_all('github.com')
|
|
35
|
-
# Every icon found across all strategies, deduplicated by URL
|
|
36
|
-
```
|
|
37
|
-
|
|
38
|
-
### Configuration
|
|
53
|
+
## Configuration
|
|
39
54
|
|
|
40
55
|
```ruby
|
|
41
56
|
config = BrandLogo::Config.new(
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
allow_svg:
|
|
45
|
-
|
|
46
|
-
|
|
57
|
+
min_size: 32, # Integer, { width:, height: } or BrandLogo::Dimensions
|
|
58
|
+
max_size: { width: 1024, height: 1024 },
|
|
59
|
+
allow_svg: true,
|
|
60
|
+
prefer: :square, # :square, :largest or :svg
|
|
61
|
+
target_size: 192, # pick the icon closest to (and preferably above) this size
|
|
62
|
+
color_scheme: :dark, # favour <link media="(prefers-color-scheme: dark)"> icons
|
|
63
|
+
timeout: 5, # seconds per request
|
|
64
|
+
deadline: 15, # seconds per lookup; the best icon found so far is returned
|
|
65
|
+
max_hops: 5, # redirects per request
|
|
66
|
+
max_body_bytes: 2_000_000,
|
|
67
|
+
user_agent: 'MyApp/1.0',
|
|
68
|
+
concurrency: 4, # parallel probes and fetch_many lookups
|
|
69
|
+
block_private_ips: true, # SSRF protection, see below
|
|
70
|
+
external_fallbacks: %i[google duckduckgo], # [] to never contact third parties
|
|
71
|
+
cache: Rails.cache, # anything with read(key) / write(key, value, expires_in:); nil disables
|
|
72
|
+
cache_ttl: 86_400,
|
|
73
|
+
negative_cache_ttl: 3_600
|
|
47
74
|
)
|
|
48
75
|
|
|
49
76
|
fetcher = BrandLogo::Fetcher.new(config: config)
|
|
77
|
+
other = BrandLogo::Fetcher.new(config: config.with(prefer: :largest))
|
|
50
78
|
```
|
|
51
79
|
|
|
52
|
-
|
|
80
|
+
Invalid settings raise `BrandLogo::ValidationError`. The default cache is an in-memory store
|
|
81
|
+
per `Fetcher`. Lookups that hit the deadline without a result are not cached.
|
|
53
82
|
|
|
54
|
-
|
|
83
|
+
### Custom strategies
|
|
55
84
|
|
|
56
85
|
```ruby
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
BrandLogo::Strategies::ScrapingStrategy.new(config: config, ...),
|
|
60
|
-
BrandLogo::Strategies::DuckduckgoStrategy.new(config: config, ...)
|
|
61
|
-
]
|
|
62
|
-
)
|
|
63
|
-
```
|
|
86
|
+
class MyStrategy
|
|
87
|
+
include BrandLogo::Strategies::Base
|
|
64
88
|
|
|
65
|
-
|
|
89
|
+
def stage = :document
|
|
66
90
|
|
|
67
|
-
|
|
91
|
+
def call(context)
|
|
92
|
+
page = context.page or return []
|
|
93
|
+
url = page.resolve(page.document.at('img.logo')&.[]('src')) or return []
|
|
94
|
+
[BrandLogo::Icon.new(url: url, source: :my_strategy, kind: :logo)]
|
|
95
|
+
end
|
|
96
|
+
end
|
|
68
97
|
|
|
69
|
-
|
|
70
|
-
require 'logger'
|
|
71
|
-
BrandLogo::Logging.logger.level = Logger::DEBUG # verbose output
|
|
72
|
-
BrandLogo::Logging.logger = MyCustomLogger.new # inject your own logger
|
|
98
|
+
BrandLogo::Fetcher.new(strategies: [MyStrategy.new, BrandLogo::Strategies::LinkTag.new])
|
|
73
99
|
```
|
|
74
100
|
|
|
75
|
-
##
|
|
101
|
+
## Input
|
|
102
|
+
|
|
103
|
+
`fetch` accepts a hostname or a URL (`"GitHub.com "`, `"https://github.com/about"`).
|
|
104
|
+
Internationalized domains must be given in punycode (`xn--…`); IP addresses are rejected.
|
|
76
105
|
|
|
77
|
-
|
|
78
|
-
|---|---|---|
|
|
79
|
-
| `ScrapingStrategy` | HTML `<link rel="icon">` tags | Primary — tries `https://`, `https://www.`, `http://` |
|
|
80
|
-
| `MetaTagStrategy` | `og:image`, `twitter:image` | High-res images, filter via `max_dimensions` |
|
|
81
|
-
| `ManifestStrategy` | PWA `manifest.json` `icons[]` | Best for progressive web apps |
|
|
82
|
-
| `DuckduckgoStrategy` | DuckDuckGo icon cache | Last-resort fallback |
|
|
106
|
+
## Security
|
|
83
107
|
|
|
84
|
-
|
|
108
|
+
The gem fetches URLs chosen by third-party pages. With `block_private_ips: true` (default),
|
|
109
|
+
every request — including each redirect — is refused unless its host resolves only to public
|
|
110
|
+
addresses, and only `http`/`https` URLs are fetched. A DNS answer that changes between the check
|
|
111
|
+
and the connection (DNS rebinding) is not covered; run lookups from a network without access to
|
|
112
|
+
sensitive internal services if inputs are untrusted.
|
|
113
|
+
|
|
114
|
+
## Logging
|
|
85
115
|
|
|
86
116
|
```ruby
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
rescue BrandLogo::NoIconFoundError
|
|
90
|
-
# no icon found by any strategy
|
|
91
|
-
rescue BrandLogo::ValidationError => e
|
|
92
|
-
# invalid domain format: e.message
|
|
93
|
-
end
|
|
117
|
+
BrandLogo::Logging.logger.level = Logger::DEBUG
|
|
118
|
+
BrandLogo::Logging.logger = Rails.logger
|
|
94
119
|
```
|
|
95
120
|
|
|
121
|
+
## Sorbet
|
|
122
|
+
|
|
123
|
+
The gem is typed with Sorbet but does not depend on `sorbet-runtime`: signatures are checked
|
|
124
|
+
statically in CI, and at runtime the gem uses `sorbet-runtime` only if your bundle already has it.
|
|
125
|
+
|
|
96
126
|
## Requirements
|
|
97
127
|
|
|
98
128
|
- Ruby >= 3.2
|
|
@@ -102,6 +132,13 @@ end
|
|
|
102
132
|
Bug reports and pull requests are welcome on GitHub at
|
|
103
133
|
<https://github.com/AdVitam/brand_logo>.
|
|
104
134
|
|
|
135
|
+
```bash
|
|
136
|
+
bundle exec rspec
|
|
137
|
+
bundle exec rubocop
|
|
138
|
+
bundle exec srb tc
|
|
139
|
+
E2E=1 bundle exec rspec --tag e2e # hits real websites
|
|
140
|
+
```
|
|
141
|
+
|
|
105
142
|
## License
|
|
106
143
|
|
|
107
144
|
Released under the MIT License. See [LICENSE.txt](LICENSE.txt).
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
# typed: strict
|
|
3
|
+
|
|
4
|
+
module BrandLogo
|
|
5
|
+
# Duck-typed on read / write(expires_in:) so Rails.cache can be passed as is.
|
|
6
|
+
module Cache
|
|
7
|
+
class Memory
|
|
8
|
+
extend T::Sig
|
|
9
|
+
|
|
10
|
+
sig { params(max_entries: Integer).void }
|
|
11
|
+
def initialize(max_entries: 1_000)
|
|
12
|
+
@max_entries = max_entries
|
|
13
|
+
@entries = T.let({}, T::Hash[String, [T.untyped, Float]])
|
|
14
|
+
@mutex = T.let(Mutex.new, Mutex)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
sig { params(key: String).returns(T.untyped) }
|
|
18
|
+
def read(key)
|
|
19
|
+
@mutex.synchronize do
|
|
20
|
+
entry = @entries[key]
|
|
21
|
+
return nil unless entry
|
|
22
|
+
|
|
23
|
+
value, expires_at = entry
|
|
24
|
+
return value if now < expires_at
|
|
25
|
+
|
|
26
|
+
@entries.delete(key)
|
|
27
|
+
nil
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
sig { params(key: String, value: T.untyped, expires_in: T.any(Integer, Float)).void }
|
|
32
|
+
def write(key, value, expires_in:)
|
|
33
|
+
@mutex.synchronize do
|
|
34
|
+
@entries.delete(key)
|
|
35
|
+
@entries[key] = [value, now + expires_in]
|
|
36
|
+
@entries.shift while @entries.size > @max_entries
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
sig { returns(Float) }
|
|
43
|
+
def now
|
|
44
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC).to_f
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
# typed: strict
|
|
3
|
+
|
|
4
|
+
module BrandLogo
|
|
5
|
+
module Concurrency
|
|
6
|
+
extend T::Sig
|
|
7
|
+
|
|
8
|
+
sig do
|
|
9
|
+
type_parameters(:I, :O)
|
|
10
|
+
.params(
|
|
11
|
+
items: T::Array[T.type_parameter(:I)],
|
|
12
|
+
size: Integer,
|
|
13
|
+
block: T.proc.params(item: T.type_parameter(:I)).returns(T.type_parameter(:O))
|
|
14
|
+
)
|
|
15
|
+
.returns(T::Array[T.type_parameter(:O)])
|
|
16
|
+
end
|
|
17
|
+
def self.map(items, size:, &block)
|
|
18
|
+
return items.map(&block) if items.size <= 1 || size <= 1
|
|
19
|
+
|
|
20
|
+
results = T.let(Array.new(items.size), T::Array[T.untyped])
|
|
21
|
+
failures = Queue.new
|
|
22
|
+
queue = Queue.new(items.each_index.to_a).close
|
|
23
|
+
|
|
24
|
+
spawn([size, items.size].min) { work(queue, failures) { |index| results[index] = yield(items.fetch(index)) } }
|
|
25
|
+
raise failures.pop unless failures.empty?
|
|
26
|
+
|
|
27
|
+
results
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
sig { params(count: Integer, block: T.proc.void).void }
|
|
31
|
+
def self.spawn(count, &block)
|
|
32
|
+
Array.new(count) do
|
|
33
|
+
Thread.new do
|
|
34
|
+
Thread.current.report_on_exception = false
|
|
35
|
+
yield
|
|
36
|
+
end
|
|
37
|
+
end.each(&:join)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
sig { params(queue: Thread::Queue, failures: Thread::Queue, block: T.proc.params(index: Integer).void).void }
|
|
41
|
+
def self.work(queue, failures, &block)
|
|
42
|
+
while (index = queue.pop)
|
|
43
|
+
begin
|
|
44
|
+
yield(index)
|
|
45
|
+
rescue StandardError => e
|
|
46
|
+
failures << e
|
|
47
|
+
queue.clear
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
private_class_method :spawn, :work
|
|
52
|
+
end
|
|
53
|
+
end
|
data/lib/brand_logo/config.rb
CHANGED
|
@@ -1,60 +1,112 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
# typed: strict
|
|
3
3
|
|
|
4
|
-
require '
|
|
4
|
+
require 'digest'
|
|
5
5
|
|
|
6
6
|
module BrandLogo
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
# config = BrandLogo::Config.new(min_dimensions: { width: 32, height: 32 }, timeout: 5)
|
|
13
|
-
class Config
|
|
7
|
+
Config = Data.define(
|
|
8
|
+
:min_size, :max_size, :allow_svg, :prefer, :target_size, :color_scheme,
|
|
9
|
+
:timeout, :deadline, :max_hops, :max_body_bytes, :user_agent, :concurrency,
|
|
10
|
+
:block_private_ips, :external_fallbacks, :cache, :cache_ttl, :negative_cache_ttl
|
|
11
|
+
) do
|
|
14
12
|
extend T::Sig
|
|
15
13
|
|
|
16
|
-
DEFAULT_TIMEOUT = T.let(10, Integer)
|
|
17
|
-
DEFAULT_MAX_HOPS = T.let(5, Integer)
|
|
18
|
-
DEFAULT_MIN_DIMENSIONS = T.let({ width: 0, height: 0 }.freeze, T::Hash[Symbol, Integer])
|
|
19
|
-
DEFAULT_DIMENSIONS = T.let({ width: 16, height: 16 }.freeze, T::Hash[Symbol, Integer])
|
|
20
|
-
DEFAULT_FAVICON_PATH = T.let('/favicon.ico', String)
|
|
21
|
-
|
|
22
|
-
sig { returns(T::Hash[Symbol, Integer]) }
|
|
23
|
-
attr_reader :min_dimensions
|
|
24
|
-
|
|
25
|
-
sig { returns(T.nilable(T::Hash[Symbol, Integer])) }
|
|
26
|
-
attr_reader :max_dimensions
|
|
27
|
-
|
|
28
|
-
sig { returns(T::Boolean) }
|
|
29
|
-
attr_reader :allow_svg
|
|
30
|
-
|
|
31
|
-
sig { returns(Integer) }
|
|
32
|
-
attr_reader :timeout
|
|
33
|
-
|
|
34
|
-
sig { returns(Integer) }
|
|
35
|
-
attr_reader :max_hops
|
|
36
|
-
|
|
37
14
|
sig do
|
|
38
15
|
params(
|
|
39
|
-
|
|
40
|
-
|
|
16
|
+
min_size: T.untyped,
|
|
17
|
+
max_size: T.untyped,
|
|
41
18
|
allow_svg: T::Boolean,
|
|
42
|
-
|
|
43
|
-
|
|
19
|
+
prefer: Symbol,
|
|
20
|
+
target_size: T.nilable(Integer),
|
|
21
|
+
color_scheme: T.nilable(Symbol),
|
|
22
|
+
timeout: Numeric,
|
|
23
|
+
deadline: Numeric,
|
|
24
|
+
max_hops: Integer,
|
|
25
|
+
max_body_bytes: Integer,
|
|
26
|
+
user_agent: String,
|
|
27
|
+
concurrency: Integer,
|
|
28
|
+
block_private_ips: T::Boolean,
|
|
29
|
+
external_fallbacks: T::Array[Symbol],
|
|
30
|
+
cache: T.untyped,
|
|
31
|
+
cache_ttl: Integer,
|
|
32
|
+
negative_cache_ttl: Integer
|
|
44
33
|
).void
|
|
45
34
|
end
|
|
46
35
|
def initialize(
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
max_hops: DEFAULT_MAX_HOPS
|
|
36
|
+
min_size: nil, max_size: nil, allow_svg: true, prefer: :square, target_size: nil, color_scheme: nil,
|
|
37
|
+
timeout: 5, deadline: 15, max_hops: 5, max_body_bytes: 2_000_000, user_agent: Config::USER_AGENT,
|
|
38
|
+
concurrency: 4, block_private_ips: true, external_fallbacks: Config::EXTERNAL_FALLBACKS, cache: Cache::Memory.new,
|
|
39
|
+
cache_ttl: 86_400, negative_cache_ttl: 3_600
|
|
52
40
|
)
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
41
|
+
super(
|
|
42
|
+
min_size: Dimensions.coerce(min_size), max_size: Dimensions.coerce(max_size), allow_svg: allow_svg,
|
|
43
|
+
prefer: prefer, target_size: target_size, color_scheme: color_scheme, timeout: timeout,
|
|
44
|
+
deadline: deadline, max_hops: max_hops, max_body_bytes: max_body_bytes, user_agent: user_agent,
|
|
45
|
+
concurrency: concurrency, block_private_ips: block_private_ips,
|
|
46
|
+
external_fallbacks: external_fallbacks.dup.freeze, cache: cache, cache_ttl: cache_ttl,
|
|
47
|
+
negative_cache_ttl: negative_cache_ttl
|
|
48
|
+
)
|
|
49
|
+
validate!
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Data#with skips initialize before Ruby 3.3, which would bypass coercion and validation.
|
|
53
|
+
sig { params(changes: T.untyped).returns(Config) }
|
|
54
|
+
def with(**changes)
|
|
55
|
+
self.class.new(**to_h, **changes)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Identifies the settings that change which icon wins, so cached results never leak across them.
|
|
59
|
+
sig { returns(String) }
|
|
60
|
+
def cache_digest
|
|
61
|
+
Digest::SHA256.hexdigest(
|
|
62
|
+
[min_size, max_size, allow_svg, prefer, target_size, color_scheme, external_fallbacks,
|
|
63
|
+
block_private_ips].inspect
|
|
64
|
+
)[0, 12].to_s
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
private
|
|
68
|
+
|
|
69
|
+
sig { void }
|
|
70
|
+
def validate!
|
|
71
|
+
validate_choices!
|
|
72
|
+
validate_numbers!
|
|
73
|
+
check(cache.nil? || (cache.respond_to?(:read) && cache.respond_to?(:write)), 'cache must respond to read/write')
|
|
74
|
+
check(!(min_size && max_size) || T.must(max_size).at_least?(T.must(min_size)), 'max_size must be >= min_size')
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
sig { void }
|
|
78
|
+
def validate_choices!
|
|
79
|
+
check(Config::PREFERENCES.include?(prefer), "prefer must be one of #{Config::PREFERENCES}")
|
|
80
|
+
check(color_scheme.nil? || Config::COLOR_SCHEMES.include?(color_scheme),
|
|
81
|
+
"color_scheme must be nil or one of #{Config::COLOR_SCHEMES}")
|
|
82
|
+
check((external_fallbacks - Config::EXTERNAL_FALLBACKS).empty?,
|
|
83
|
+
"external_fallbacks must be within #{Config::EXTERNAL_FALLBACKS}")
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
sig { void }
|
|
87
|
+
def validate_numbers!
|
|
88
|
+
size = target_size
|
|
89
|
+
check(size.nil? || size.positive?, 'target_size must be positive')
|
|
90
|
+
%i[timeout deadline max_body_bytes concurrency].each do |key|
|
|
91
|
+
check(public_send(key).positive?, "#{key} must be positive")
|
|
92
|
+
end
|
|
93
|
+
%i[max_hops cache_ttl negative_cache_ttl].each { |key| check(!public_send(key).negative?, "#{key} must be >= 0") }
|
|
58
94
|
end
|
|
95
|
+
|
|
96
|
+
sig { params(condition: T::Boolean, message: String).void }
|
|
97
|
+
def check(condition, message)
|
|
98
|
+
raise ValidationError, message unless condition
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# Reopened because constants defined inside a Data.define block would land on BrandLogo.
|
|
103
|
+
class Config
|
|
104
|
+
PREFERENCES = T.let(%i[square largest svg].freeze, T::Array[Symbol])
|
|
105
|
+
COLOR_SCHEMES = T.let(%i[light dark].freeze, T::Array[Symbol])
|
|
106
|
+
EXTERNAL_FALLBACKS = T.let(%i[google duckduckgo].freeze, T::Array[Symbol])
|
|
107
|
+
USER_AGENT = T.let(
|
|
108
|
+
'Mozilla/5.0 (compatible; BrandLogo/2; +https://github.com/AdVitam/brand_logo)',
|
|
109
|
+
String
|
|
110
|
+
)
|
|
59
111
|
end
|
|
60
112
|
end
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
# typed: strict
|
|
3
|
+
|
|
4
|
+
require 'uri'
|
|
5
|
+
|
|
6
|
+
module BrandLogo
|
|
7
|
+
class Context
|
|
8
|
+
extend T::Sig
|
|
9
|
+
|
|
10
|
+
sig { returns(String) }
|
|
11
|
+
attr_reader :domain
|
|
12
|
+
|
|
13
|
+
sig { returns(Config) }
|
|
14
|
+
attr_reader :config
|
|
15
|
+
|
|
16
|
+
sig { returns(Deadline) }
|
|
17
|
+
attr_reader :deadline
|
|
18
|
+
|
|
19
|
+
sig { params(domain: String, config: Config, http: Http::Client, deadline: T.nilable(Deadline)).void }
|
|
20
|
+
def initialize(domain:, config:, http:, deadline: nil)
|
|
21
|
+
@domain = domain
|
|
22
|
+
@config = config
|
|
23
|
+
@http = http
|
|
24
|
+
@deadline = T.let(deadline || Deadline.new(config.deadline), Deadline)
|
|
25
|
+
@lock = T.let(Mutex.new, Mutex)
|
|
26
|
+
@page = T.let(nil, T.nilable(Page))
|
|
27
|
+
@page_loaded = T.let(false, T::Boolean)
|
|
28
|
+
@probes = T.let({}, T::Hash[String, T.nilable(ImageProbe::Result)])
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
sig { params(url: String, max_bytes: T.nilable(Integer), range: T.nilable(T::Range[Integer])).returns(T.nilable(Http::Response)) }
|
|
32
|
+
def get(url, max_bytes: nil, range: nil)
|
|
33
|
+
@http.get(url, deadline: @deadline, max_bytes: max_bytes || @config.max_body_bytes, range: range)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
sig { returns(T.nilable(Page)) }
|
|
37
|
+
def page
|
|
38
|
+
@lock.synchronize do
|
|
39
|
+
unless @page_loaded
|
|
40
|
+
@page = Page.fetch(self)
|
|
41
|
+
@page_loaded = true
|
|
42
|
+
end
|
|
43
|
+
@page
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
sig { params(url: String).returns(T.nilable(ImageProbe::Result)) }
|
|
48
|
+
def probe(url)
|
|
49
|
+
return @probes[url] if @lock.synchronize { @probes.key?(url) }
|
|
50
|
+
|
|
51
|
+
body = url.start_with?('data:') ? decode_data_uri(url) : get(url, max_bytes: ImageProbe::BYTES, range: 0...ImageProbe::BYTES)&.body
|
|
52
|
+
result = body && ImageProbe.analyze(body)
|
|
53
|
+
@lock.synchronize { @probes[url] = result }
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
private
|
|
57
|
+
|
|
58
|
+
sig { params(url: String).returns(T.nilable(String)) }
|
|
59
|
+
def decode_data_uri(url)
|
|
60
|
+
header, payload = url.split(',', 2)
|
|
61
|
+
return nil unless header && payload
|
|
62
|
+
|
|
63
|
+
header.end_with?(';base64') ? payload.unpack1('m') : URI.decode_www_form_component(payload)
|
|
64
|
+
rescue ArgumentError
|
|
65
|
+
nil
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
# typed: strict
|
|
3
|
+
|
|
4
|
+
module BrandLogo
|
|
5
|
+
class Deadline
|
|
6
|
+
extend T::Sig
|
|
7
|
+
|
|
8
|
+
sig { params(seconds: Numeric).void }
|
|
9
|
+
def initialize(seconds)
|
|
10
|
+
@expires_at = T.let(now + seconds.to_f, Float)
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
sig { returns(Float) }
|
|
14
|
+
def remaining
|
|
15
|
+
[@expires_at - now, 0.0].max
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
sig { returns(T::Boolean) }
|
|
19
|
+
def expired?
|
|
20
|
+
remaining.zero?
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
private
|
|
24
|
+
|
|
25
|
+
sig { returns(Float) }
|
|
26
|
+
def now
|
|
27
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC).to_f
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|