pinterest-dl 1.0.3 → 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 +40 -0
- data/LICENSE +13 -0
- data/README.md +142 -31
- data/bin/pinterest-dl +107 -0
- data/lib/pinterest-dl.rb +15 -191
- data/lib/pinterest_dl/api.rb +84 -0
- data/lib/pinterest_dl/board.rb +106 -0
- data/lib/pinterest_dl/client.rb +111 -0
- data/lib/pinterest_dl/configuration.rb +46 -0
- data/lib/pinterest_dl/downloader.rb +57 -0
- data/lib/pinterest_dl/errors.rb +49 -0
- data/lib/pinterest_dl/extractors/image.rb +47 -0
- data/lib/pinterest_dl/extractors/video.rb +61 -0
- data/lib/pinterest_dl/progress_bar.rb +21 -0
- data/lib/pinterest_dl/search.rb +142 -0
- data/lib/pinterest_dl/version.rb +6 -0
- metadata +31 -8
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Monji024
|
|
4
|
+
module PinterestDL
|
|
5
|
+
PIN_URL_PATTERN = %r{pinterest\.[a-z.]+/pin/}i.freeze
|
|
6
|
+
|
|
7
|
+
class << self
|
|
8
|
+
def config
|
|
9
|
+
@config ||= Configuration.new
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def configure
|
|
13
|
+
yield config
|
|
14
|
+
config
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def logger
|
|
18
|
+
config.logger
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def get_image_url(pin_url, quality: config.quality, client: Client.new(config: config))
|
|
22
|
+
validate_pin_url!(pin_url)
|
|
23
|
+
html = client.get(pin_url)
|
|
24
|
+
url = Extractors::Image.extract(html, quality: quality)
|
|
25
|
+
raise NotFoundError, "No image found at #{pin_url}" unless url
|
|
26
|
+
|
|
27
|
+
url
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def get_video_url(pin_url, client: Client.new(config: config))
|
|
31
|
+
validate_pin_url!(pin_url)
|
|
32
|
+
html = client.get(pin_url)
|
|
33
|
+
url = Extractors::Video.extract(html)
|
|
34
|
+
raise NotFoundError, "No video found at #{pin_url}" unless url
|
|
35
|
+
|
|
36
|
+
url
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def get_media(pin_url, quality: config.quality)
|
|
40
|
+
client = Client.new(config: config)
|
|
41
|
+
html = client.get(pin_url)
|
|
42
|
+
{
|
|
43
|
+
image_url: Extractors::Image.extract(html, quality: quality),
|
|
44
|
+
video_url: Extractors::Video.extract(html)
|
|
45
|
+
}
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def download(url, path:)
|
|
49
|
+
Downloader.new(config: config).download(url, path: path)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def download_batch(urls, dir:, filename: nil, &progress)
|
|
53
|
+
Downloader.new(config: config).download_batch(urls, dir: dir, filename: filename, &progress)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def search(query, limit: 25, pages: 5)
|
|
57
|
+
Search.new(config: config).call(query, limit: limit, pages: pages)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def search_json(query, limit: 25)
|
|
61
|
+
JSON.pretty_generate(search(query, limit: limit))
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def search_images(query, limit: 25)
|
|
65
|
+
search(query, limit: limit).filter_map { |p| p[:image_url] }
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def search_videos(query, limit: 25)
|
|
69
|
+
search(query, limit: limit).filter_map { |p| p[:video_url] }
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def board(board_url, limit: 50, pages: 10)
|
|
73
|
+
Board.new(config: config).call(board_url, limit: limit, pages: pages)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
private
|
|
77
|
+
|
|
78
|
+
def validate_pin_url!(pin_url)
|
|
79
|
+
return if pin_url.to_s.match?(PIN_URL_PATTERN)
|
|
80
|
+
|
|
81
|
+
raise InvalidURLError, "Not a Pinterest pin URL: #{pin_url.inspect}"
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
require 'net/http'
|
|
5
|
+
require 'uri'
|
|
6
|
+
|
|
7
|
+
module PinterestDL
|
|
8
|
+
# Handles Pinterest's internal search resource, including pagination
|
|
9
|
+
# via the `bookmarks` cursor Pinterest returns with each page.
|
|
10
|
+
class Search
|
|
11
|
+
RESOURCE_URL = 'https://www.pinterest.com/resource/BaseSearchResource/get/'
|
|
12
|
+
|
|
13
|
+
def initialize(client: PinterestDL::Client.new, config: PinterestDL.config)
|
|
14
|
+
@client = client
|
|
15
|
+
@config = config
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# @param query [String]
|
|
19
|
+
# @param limit [Integer] max number of pins to return in total
|
|
20
|
+
# @param pages [Integer] max number of pages to fetch (pagination safety valve)
|
|
21
|
+
# @return [Array<Hash>] pin metadata hashes
|
|
22
|
+
def call(query, limit: 25, pages: 5)
|
|
23
|
+
results = []
|
|
24
|
+
bookmarks = nil
|
|
25
|
+
pages_fetched = 0
|
|
26
|
+
|
|
27
|
+
loop do
|
|
28
|
+
page = fetch_page(query, bookmarks: bookmarks)
|
|
29
|
+
pins = page[:pins]
|
|
30
|
+
results.concat(pins)
|
|
31
|
+
bookmarks = page[:bookmarks]
|
|
32
|
+
pages_fetched += 1
|
|
33
|
+
|
|
34
|
+
break if results.size >= limit
|
|
35
|
+
break if bookmarks.nil? || bookmarks.empty? || bookmarks == ['-end-']
|
|
36
|
+
break if pages_fetched >= pages
|
|
37
|
+
break if pins.empty?
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
results.first(limit)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
private
|
|
44
|
+
|
|
45
|
+
def fetch_page(query, bookmarks: nil)
|
|
46
|
+
source_path = "/search/pins/?q=#{URI.encode_www_form_component(query)}"
|
|
47
|
+
home_uri = URI("https://www.pinterest.com#{source_path}")
|
|
48
|
+
|
|
49
|
+
home_response = @client.raw_get(home_uri.to_s)
|
|
50
|
+
session_cookies = (home_response.get_fields('set-cookie') || []).map { |c| c.split(';').first }
|
|
51
|
+
cookie_header = [@config.cookies, session_cookies.join('; ')].compact.reject(&:empty?).join('; ')
|
|
52
|
+
csrf = session_cookies.find { |c| c.start_with?('csrftoken=') }&.split('=', 2)&.last || 'undefined'
|
|
53
|
+
|
|
54
|
+
options = { query: query, scope: 'pins', page_size: 25 }
|
|
55
|
+
options[:bookmarks] = bookmarks if bookmarks && !bookmarks.empty?
|
|
56
|
+
data = { options: options, context: {} }.to_json
|
|
57
|
+
|
|
58
|
+
res_uri = URI(RESOURCE_URL)
|
|
59
|
+
res_uri.query = URI.encode_www_form('source_url' => source_path, 'data' => data)
|
|
60
|
+
|
|
61
|
+
body = @client.get(res_uri.to_s, use_cache: false, headers: {
|
|
62
|
+
'Accept' => 'application/json, text/javascript, */*, q=0.01',
|
|
63
|
+
'X-Requested-With' => 'XMLHttpRequest',
|
|
64
|
+
'X-Pinterest-PWS-Handler' => 'www/search/[scope].js',
|
|
65
|
+
'X-CSRFToken' => csrf,
|
|
66
|
+
'Referer' => home_uri.to_s,
|
|
67
|
+
'Cookie' => cookie_header
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
json = safe_parse(body)
|
|
71
|
+
raise PinterestDL::NotFoundError, "No search results for \"#{query}\"" unless json
|
|
72
|
+
|
|
73
|
+
data_node = json.dig('resource_response', 'data') || {}
|
|
74
|
+
raw_results = data_node['results'] || []
|
|
75
|
+
next_bookmarks = json.dig('resource_response', 'bookmark') ||
|
|
76
|
+
json.dig('resource', 'options', 'bookmarks')
|
|
77
|
+
|
|
78
|
+
{ pins: raw_results.filter_map { |r| build_pin(r) }, bookmarks: Array(next_bookmarks) }
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def build_pin(raw)
|
|
82
|
+
id = raw['id']
|
|
83
|
+
return nil unless id
|
|
84
|
+
|
|
85
|
+
orig_image = raw.dig('images', 'orig', 'url')
|
|
86
|
+
fallback_image = raw['images']&.values&.map { |v| v.is_a?(Hash) ? v['url'] : nil }&.compact&.last
|
|
87
|
+
video_url = raw.dig('videos', 'video_list')&.values&.first&.dig('url')
|
|
88
|
+
|
|
89
|
+
{
|
|
90
|
+
id: id,
|
|
91
|
+
pin_url: "https://www.pinterest.com/pin/#{id}/",
|
|
92
|
+
title: raw['title'] || raw['grid_title'],
|
|
93
|
+
description: raw['description'],
|
|
94
|
+
creator: raw.dig('pinner', 'username') || raw.dig('pinner', 'full_name'),
|
|
95
|
+
image_url: orig_image || fallback_image,
|
|
96
|
+
video_url: video_url
|
|
97
|
+
}
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def safe_parse(body)
|
|
101
|
+
JSON.parse(body)
|
|
102
|
+
rescue JSON::ParserError, TypeError
|
|
103
|
+
nil
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Monji024
|
|
4
|
+
require 'net/http'
|
|
5
|
+
require 'uri'
|
|
6
|
+
require 'openssl'
|
|
7
|
+
|
|
8
|
+
module PinterestDL
|
|
9
|
+
class Client
|
|
10
|
+
def initialize(config: PinterestDL.config)
|
|
11
|
+
@config = config
|
|
12
|
+
@cache = {}
|
|
13
|
+
@last_request_at = nil
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def get(url, headers: {}, use_cache: true)
|
|
17
|
+
cache_key = [url, headers].hash
|
|
18
|
+
return @cache[cache_key] if use_cache && @cache.key?(cache_key)
|
|
19
|
+
|
|
20
|
+
response = request_with_retries(url, headers: headers)
|
|
21
|
+
body = handle_response(response, url)
|
|
22
|
+
@cache[cache_key] = body if use_cache
|
|
23
|
+
body
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def raw_get(url, headers: {})
|
|
27
|
+
response = request_with_retries(url, headers: headers)
|
|
28
|
+
handle_response(response, url, raw: true)
|
|
29
|
+
response
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def clear_cache
|
|
33
|
+
@cache.clear
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
private
|
|
37
|
+
|
|
38
|
+
def request_with_retries(url, headers: {})
|
|
39
|
+
attempts = 0
|
|
40
|
+
begin
|
|
41
|
+
throttle!
|
|
42
|
+
uri = URI(url)
|
|
43
|
+
http = build_http(uri)
|
|
44
|
+
req = Net::HTTP::Get.new(uri, default_headers.merge(headers))
|
|
45
|
+
http.request(req)
|
|
46
|
+
rescue Timeout::Error, Errno::ECONNRESET, Errno::ECONNREFUSED,
|
|
47
|
+
SocketError, OpenSSL::SSL::SSLError, EOFError => e
|
|
48
|
+
attempts += 1
|
|
49
|
+
if attempts <= @config.max_retries
|
|
50
|
+
@config.logger.warn("request to #{url} failed (#{e.class}), retrying (#{attempts}/#{@config.max_retries})")
|
|
51
|
+
sleep(@config.retry_wait * attempts)
|
|
52
|
+
retry
|
|
53
|
+
end
|
|
54
|
+
raise PinterestDL::NetworkError.new("Failed to reach #{url}: #{e.message}", cause_error: e)
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def handle_response(response, url, raw: false)
|
|
59
|
+
case response
|
|
60
|
+
when Net::HTTPSuccess
|
|
61
|
+
raw ? response : response.body
|
|
62
|
+
when Net::HTTPTooManyRequests
|
|
63
|
+
raise PinterestDL::RateLimitError.new(
|
|
64
|
+
"Rate limited while requesting #{url}",
|
|
65
|
+
retry_after: response['Retry-After']
|
|
66
|
+
)
|
|
67
|
+
when Net::HTTPNotFound
|
|
68
|
+
raise PinterestDL::NotFoundError, "Nothing found at #{url}"
|
|
69
|
+
when Net::HTTPRedirection
|
|
70
|
+
location = response['location']
|
|
71
|
+
raise PinterestDL::NotFoundError, "Redirected without a location header (#{url})" unless location
|
|
72
|
+
|
|
73
|
+
location = URI.join(url, location).to_s if location.start_with?('/')
|
|
74
|
+
get_or_raw(location, raw: raw)
|
|
75
|
+
when Net::HTTPForbidden
|
|
76
|
+
raise PinterestDL::RateLimitError, "Request to #{url} was blocked (HTTP 403)"
|
|
77
|
+
else
|
|
78
|
+
raise PinterestDL::NetworkError, "Unexpected response #{response.code} from #{url}"
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def get_or_raw(url, raw:)
|
|
83
|
+
raw ? raw_get(url) : get(url)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def build_http(uri)
|
|
87
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
88
|
+
http.use_ssl = (uri.scheme == 'https')
|
|
89
|
+
http.open_timeout = @config.open_timeout
|
|
90
|
+
http.read_timeout = @config.read_timeout
|
|
91
|
+
http
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def default_headers
|
|
95
|
+
headers = { 'User-Agent' => @config.user_agent }
|
|
96
|
+
headers['Cookie'] = @config.cookies if @config.authenticated?
|
|
97
|
+
headers
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def throttle!
|
|
101
|
+
return if @config.rate_limit_interval <= 0
|
|
102
|
+
return unless @last_request_at
|
|
103
|
+
|
|
104
|
+
elapsed = Time.now - @last_request_at
|
|
105
|
+
wait = @config.rate_limit_interval - elapsed
|
|
106
|
+
sleep(wait) if wait.positive?
|
|
107
|
+
ensure
|
|
108
|
+
@last_request_at = Time.now
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'logger'
|
|
4
|
+
|
|
5
|
+
module PinterestDL
|
|
6
|
+
class Configuration
|
|
7
|
+
DEFAULT_USER_AGENT =
|
|
8
|
+
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 ' \
|
|
9
|
+
'(KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36'
|
|
10
|
+
|
|
11
|
+
VALID_QUALITIES = %i[originals 736x 474x 236x].freeze
|
|
12
|
+
|
|
13
|
+
attr_accessor :cookies, :user_agent, :open_timeout, :read_timeout,
|
|
14
|
+
:max_retries, :retry_wait, :logger,
|
|
15
|
+
:rate_limit_interval
|
|
16
|
+
|
|
17
|
+
attr_reader :quality
|
|
18
|
+
|
|
19
|
+
def initialize
|
|
20
|
+
@user_agent = DEFAULT_USER_AGENT
|
|
21
|
+
@cookies = nil
|
|
22
|
+
@open_timeout = 10
|
|
23
|
+
@read_timeout = 20
|
|
24
|
+
@max_retries = 3
|
|
25
|
+
@retry_wait = 1.0
|
|
26
|
+
@quality = :originals
|
|
27
|
+
@rate_limit_interval = 0.0
|
|
28
|
+
@logger = Logger.new($stdout).tap do |log|
|
|
29
|
+
log.level = Logger::WARN
|
|
30
|
+
log.formatter = proc { |severity, _time, _progname, msg| "[pinterest-dl] #{severity}: #{msg}\n" }
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def quality=(value)
|
|
35
|
+
value = value.to_sym
|
|
36
|
+
valid = VALID_QUALITIES.join(', ')
|
|
37
|
+
raise ArgumentError, "quality must be one of: #{valid}" unless VALID_QUALITIES.include?(value)
|
|
38
|
+
|
|
39
|
+
@quality = value
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def authenticated?
|
|
43
|
+
!cookies.nil? && !cookies.to_s.empty?
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'fileutils'
|
|
4
|
+
|
|
5
|
+
module PinterestDL
|
|
6
|
+
class Downloader
|
|
7
|
+
def initialize(client: PinterestDL::Client.new, config: PinterestDL.config)
|
|
8
|
+
@client = client
|
|
9
|
+
@config = config
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def download(url, path:)
|
|
13
|
+
raise ArgumentError, 'url must not be nil/empty' if url.to_s.empty?
|
|
14
|
+
|
|
15
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
16
|
+
response = @client.raw_get(url)
|
|
17
|
+
File.binwrite(path, response.body)
|
|
18
|
+
path
|
|
19
|
+
rescue PinterestDL::Error
|
|
20
|
+
raise
|
|
21
|
+
rescue StandardError => e
|
|
22
|
+
raise PinterestDL::DownloadError, "Could not download #{url} to #{path}: #{e.message}"
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def download_batch(urls, dir:, filename: nil, &progress)
|
|
26
|
+
FileUtils.mkdir_p(dir)
|
|
27
|
+
succeeded = []
|
|
28
|
+
failed = []
|
|
29
|
+
total = urls.size
|
|
30
|
+
|
|
31
|
+
urls.each_with_index do |url, index|
|
|
32
|
+
name = filename ? filename.call(url, index) : default_filename(url, index)
|
|
33
|
+
path = File.join(dir, name)
|
|
34
|
+
begin
|
|
35
|
+
download(url, path: path)
|
|
36
|
+
succeeded << path
|
|
37
|
+
rescue PinterestDL::Error => e
|
|
38
|
+
@config.logger.warn("failed to download #{url}: #{e.message}")
|
|
39
|
+
failed << { url: url, error: e }
|
|
40
|
+
end
|
|
41
|
+
progress&.call(index + 1, total, url)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
{ succeeded: succeeded, failed: failed }
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
private
|
|
48
|
+
|
|
49
|
+
def default_filename(url, index)
|
|
50
|
+
ext = File.extname(URI(url).path)
|
|
51
|
+
ext = '.jpg' if ext.nil? || ext.empty?
|
|
52
|
+
format('%<index>04d%<ext>s', index: index + 1, ext: ext)
|
|
53
|
+
rescue URI::InvalidURIError
|
|
54
|
+
format('%<index>04d.jpg', index: index + 1)
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Monji024
|
|
4
|
+
module PinterestDL
|
|
5
|
+
class Error < StandardError
|
|
6
|
+
end
|
|
7
|
+
|
|
8
|
+
class NetworkError < Error
|
|
9
|
+
attr_reader :cause_error
|
|
10
|
+
|
|
11
|
+
def initialize(message = 'A network error occurred', cause_error: nil)
|
|
12
|
+
@cause_error = cause_error
|
|
13
|
+
super(message)
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
class NotFoundError < Error
|
|
18
|
+
def initialize(message = 'The requested resource was not found')
|
|
19
|
+
super
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
class RateLimitError < Error
|
|
24
|
+
attr_reader :retry_after
|
|
25
|
+
|
|
26
|
+
def initialize(message = 'Rate limited by Pinterest', retry_after: nil)
|
|
27
|
+
@retry_after = retry_after
|
|
28
|
+
super(message)
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
class AuthenticationError < Error
|
|
33
|
+
def initialize(message = 'Authentication required (set PinterestDL.configure { |c| c.cookies = ... })')
|
|
34
|
+
super
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
class InvalidURLError < Error
|
|
39
|
+
def initialize(message = 'The given URL does not look like a valid Pinterest URL')
|
|
40
|
+
super
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
class DownloadError < Error
|
|
45
|
+
def initialize(message = 'Failed to download file')
|
|
46
|
+
super
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Monji024
|
|
4
|
+
module PinterestDL
|
|
5
|
+
module Extractors
|
|
6
|
+
module Image
|
|
7
|
+
QUALITY_PATH = {
|
|
8
|
+
originals: 'originals',
|
|
9
|
+
'736x': '736x',
|
|
10
|
+
'474x': '474x',
|
|
11
|
+
'236x': '236x'
|
|
12
|
+
}.freeze
|
|
13
|
+
|
|
14
|
+
module_function
|
|
15
|
+
|
|
16
|
+
def extract(html, quality: :originals)
|
|
17
|
+
url = from_json_ld(html) || from_open_graph(html) || from_raw_pattern(html)
|
|
18
|
+
return nil unless url
|
|
19
|
+
|
|
20
|
+
apply_quality(url, quality)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def from_json_ld(html)
|
|
24
|
+
PinterestDL::Extractors::JsonLd.content_url(html, 'ImageObject')
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def from_open_graph(html)
|
|
28
|
+
match = html.match(/<meta[^>]*property=["']og:image["'][^>]*content=["']([^"']+)["']/)
|
|
29
|
+
match && match[1]
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def from_raw_pattern(html)
|
|
33
|
+
match = html.match(%r{https://i\.pinimg\.com/(?:originals|736x|474x|236x)/[^"'\s\\]+})
|
|
34
|
+
match && match[0]
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def apply_quality(url, quality)
|
|
38
|
+
return url unless url.include?('i.pinimg.com')
|
|
39
|
+
|
|
40
|
+
target = QUALITY_PATH[quality.to_sym]
|
|
41
|
+
return url unless target
|
|
42
|
+
|
|
43
|
+
url.sub(%r{/(?:originals|736x|474x|236x)/}, "/#{target}/")
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Monji024
|
|
4
|
+
module PinterestDL
|
|
5
|
+
module Extractors
|
|
6
|
+
# Extracts the direct video URL for a pin from raw HTML.
|
|
7
|
+
module Video
|
|
8
|
+
module_function
|
|
9
|
+
|
|
10
|
+
def extract(html)
|
|
11
|
+
url = from_json_ld(html) || from_open_graph(html) || from_content_url(html)
|
|
12
|
+
return nil unless url
|
|
13
|
+
|
|
14
|
+
unescape(url)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def from_json_ld(html)
|
|
18
|
+
PinterestDL::Extractors::JsonLd.content_url(html, 'VideoObject')
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def from_open_graph(html)
|
|
22
|
+
match = html.match(/<meta[^>]*property=["']og:video["'][^>]*content=["']([^"']+)["']/)
|
|
23
|
+
match && match[1]
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def from_content_url(html)
|
|
27
|
+
match = html.match(/"contentUrl"\s*:\s*"([^"]+\.mp4[^"]*)"/)
|
|
28
|
+
match && match[1]
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def unescape(url)
|
|
32
|
+
url.gsub('\\u002F', '/').gsub('\\', '')
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Shared helper for both extractors: pulls a `contentUrl` out of an
|
|
37
|
+
# `application/ld+json` <script> tag matching the given @type.
|
|
38
|
+
module JsonLd
|
|
39
|
+
module_function
|
|
40
|
+
|
|
41
|
+
def content_url(html, type)
|
|
42
|
+
html.to_s.scan(%r{<script[^>]*type=["']application/ld\+json["'][^>]*>(.*?)</script>}m).each do |(raw)|
|
|
43
|
+
data = safe_parse(raw)
|
|
44
|
+
next unless data
|
|
45
|
+
|
|
46
|
+
data = data[0] if data.is_a?(Array) && !data.empty?
|
|
47
|
+
next unless data.is_a?(Hash)
|
|
48
|
+
|
|
49
|
+
return data['contentUrl'] if data['@type'] == type && data['contentUrl']
|
|
50
|
+
end
|
|
51
|
+
nil
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def safe_parse(raw)
|
|
55
|
+
JSON.parse(raw)
|
|
56
|
+
rescue JSON::ParserError
|
|
57
|
+
nil
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Monji024
|
|
4
|
+
module PinterestDL
|
|
5
|
+
class ProgressBar
|
|
6
|
+
def initialize(total:, width: 30, out: $stdout)
|
|
7
|
+
@total = total.zero? ? 1 : total
|
|
8
|
+
@width = width
|
|
9
|
+
@out = out
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def update(done)
|
|
13
|
+
ratio = done.to_f / @total
|
|
14
|
+
filled = (@width * ratio).round
|
|
15
|
+
bar = ('#' * filled) + ('-' * (@width - filled))
|
|
16
|
+
@out.print "\r[#{bar}] #{done}/#{@total} (#{(ratio * 100).round}%)"
|
|
17
|
+
@out.print "\n" if done >= @total
|
|
18
|
+
@out.flush
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|