pinterest-dl 1.0.1 → 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.
@@ -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
@@ -0,0 +1,142 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'net/http'
5
+ require 'uri'
6
+
7
+ module PinterestDL
8
+ class Search
9
+ RESOURCE_URL = 'https://www.pinterest.com/resource/BaseSearchResource/get/'
10
+
11
+ def initialize(client: PinterestDL::Client.new, config: PinterestDL.config)
12
+ @client = client
13
+ @config = config
14
+ end
15
+
16
+ def call(query, limit: 25, pages: 5)
17
+ results = []
18
+ bookmarks = nil
19
+ pages_fetched = 0
20
+
21
+ loop do
22
+ page = fetch_page(query, bookmarks: bookmarks)
23
+ pins = page[:pins]
24
+ results.concat(pins)
25
+ bookmarks = page[:bookmarks]
26
+ pages_fetched += 1
27
+
28
+ break if results.size >= limit
29
+ break if bookmarks.nil? || bookmarks.empty? || bookmarks == ['-end-']
30
+ break if pages_fetched >= pages
31
+ break if pins.empty?
32
+ end
33
+
34
+ results.first(limit)
35
+ end
36
+
37
+ private
38
+
39
+ def fetch_page(query, bookmarks: nil)
40
+ session = establish_session(query)
41
+ body = @client.get(
42
+ build_search_url(session[:source_path], query, bookmarks),
43
+ use_cache: false,
44
+ headers: search_headers(session)
45
+ )
46
+
47
+ json = safe_parse(body)
48
+ raise PinterestDL::NotFoundError, "No search results for \"#{query}\"" unless json
49
+
50
+ parse_search_response(json)
51
+ end
52
+
53
+ def establish_session(query)
54
+ source_path = "/search/pins/?q=#{URI.encode_www_form_component(query)}"
55
+ home_uri = URI("https://www.pinterest.com#{source_path}")
56
+ home_response = @client.raw_get(home_uri.to_s)
57
+ cookies = (home_response.get_fields('set-cookie') || []).map { |c| c.split(';').first }
58
+
59
+ { source_path: source_path, home_uri: home_uri, cookies: cookies, csrf: csrf_token(cookies) }
60
+ end
61
+
62
+ def csrf_token(cookies)
63
+ token_cookie = cookies.find { |c| c.start_with?('csrftoken=') }
64
+ token_cookie ? token_cookie.split('=', 2).last : 'undefined'
65
+ end
66
+
67
+ def build_search_url(source_path, query, bookmarks)
68
+ options = { query: query, scope: 'pins', page_size: 25 }
69
+ options[:bookmarks] = bookmarks if bookmarks && !bookmarks.empty?
70
+ data = { options: options, context: {} }.to_json
71
+
72
+ res_uri = URI(RESOURCE_URL)
73
+ res_uri.query = URI.encode_www_form('source_url' => source_path, 'data' => data)
74
+ res_uri.to_s
75
+ end
76
+
77
+ def search_headers(session)
78
+ cookie_header = [@config.cookies, session[:cookies].join('; ')].compact.reject(&:empty?).join('; ')
79
+
80
+ {
81
+ 'Accept' => 'application/json, text/javascript, */*, q=0.01',
82
+ 'X-Requested-With' => 'XMLHttpRequest',
83
+ 'X-Pinterest-PWS-Handler' => 'www/search/[scope].js',
84
+ 'X-CSRFToken' => session[:csrf],
85
+ 'Referer' => session[:home_uri].to_s,
86
+ 'Cookie' => cookie_header
87
+ }
88
+ end
89
+
90
+ def parse_search_response(json)
91
+ data_node = json.dig('resource_response', 'data') || {}
92
+ raw_results = data_node['results'] || []
93
+ next_bookmarks = json.dig('resource_response', 'bookmark') ||
94
+ json.dig('resource', 'options', 'bookmarks')
95
+
96
+ { pins: raw_results.filter_map { |r| build_pin(r) }, bookmarks: Array(next_bookmarks) }
97
+ end
98
+
99
+ def build_pin(raw)
100
+ id = raw['id']
101
+ return nil unless id
102
+
103
+ {
104
+ id: id,
105
+ pin_url: "https://www.pinterest.com/pin/#{id}/",
106
+ title: raw['title'] || raw['grid_title'],
107
+ description: raw['description'],
108
+ creator: pin_creator(raw),
109
+ image_url: pin_image_url(raw),
110
+ video_url: first_video_url(raw)
111
+ }
112
+ end
113
+
114
+ def pin_creator(raw)
115
+ raw.dig('pinner', 'username') || raw.dig('pinner', 'full_name')
116
+ end
117
+
118
+ def pin_image_url(raw)
119
+ raw.dig('images', 'orig', 'url') || fallback_image_url(raw)
120
+ end
121
+
122
+ def fallback_image_url(raw)
123
+ images = raw['images']
124
+ return nil unless images
125
+
126
+ images.values.filter_map { |v| v['url'] if v.is_a?(Hash) }.last
127
+ end
128
+
129
+ def first_video_url(raw)
130
+ video_list = raw.dig('videos', 'video_list')
131
+ return nil unless video_list
132
+
133
+ video_list.values.first&.dig('url')
134
+ end
135
+
136
+ def safe_parse(body)
137
+ JSON.parse(body)
138
+ rescue JSON::ParserError, TypeError
139
+ nil
140
+ end
141
+ end
142
+ end
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Monji024
4
+ module PinterestDL
5
+ VERSION = '2.0.0'
6
+ end