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.
@@ -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