pinterest-dl 2.0.0 → 2.0.3

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 6c0b3841a1ae58f40462a3b5e3545a7f72c8e3e99457e34a046a78a06598ad5c
4
- data.tar.gz: 220569a87bab3bfe8776169a97cf373278402a8ad49190deb761e749010f5798
3
+ metadata.gz: 2ba41b3cdd44c6463cfffde77fc4b9db8185f47b1d62e3a9555b9408bfd4f197
4
+ data.tar.gz: b032e121ffb9750ed128b0a0c6ce5d0266dea8db4fbc99ee1bd6579e8f680d1a
5
5
  SHA512:
6
- metadata.gz: aa67c66ab2f7bbfb5c58de173ec73b1b077ea29bdb316623f1a2d9abf4a6f3a2c26388127a167a4955fa0f5ac63dbb7705963cad9d4bbf018aaa70ec24e53cad
7
- data.tar.gz: 0ea15400b28788771709772a9d5058ca392231a2c27270b310ee634847b075e209531182bfb5eec1873c4d4b058d6b96567a8bf0503a7e533c1b6df4d6dc6972
6
+ metadata.gz: 11fe875c2a1eabda28fb7f7b4f342b33565dc141b5fd7ff06cac800595a834c9156abd9fe61544f75eed944f0553fc180183cad5ac7fdef6ca13a03023e179ae
7
+ data.tar.gz: dc3600f15608c7717f2010cceef198e636e423b3788ee5763ff4e49fdfd19f49cef545e5206d1a7a0981d658194a97745aecbc0f373da038062f381b2ad74ec6
data/CHANGELOG.md CHANGED
@@ -3,6 +3,28 @@
3
3
  All notable changes to this project are documented here.
4
4
  This project follows [Semantic Versioning](https://semver.org/).
5
5
 
6
+ ## [2.0.2]
7
+
8
+ ### Fixed
9
+ - **`PinterestDL::Board` was broken.** `lib/pinterest_dl/board.rb` accidentally
10
+ defined `class Search` instead of `class Board` (with a full copy of the old
11
+ search logic), so it silently collided with `PinterestDL::Search` at load
12
+ time and `PinterestDL.board(...)` never worked correctly. Restored the
13
+ correct `Board` class backed by Pinterest's `BoardFeedResource`.
14
+ - `pin.it/...` short links were rejected by `get_image_url` / `get_video_url` /
15
+ `get_media` even though Pinterest itself redirects them to a normal pin
16
+ page. `PIN_URL_PATTERN` now accepts `pin.it/...` links too.
17
+ - The raw-HTML fallback regex used to find an image when no `og:image` or
18
+ JSON-LD tag was present could "bleed" past the actual image URL into
19
+ trailing inline CSS (e.g. `...img.png)}._YsBbF{border:0...`), producing a
20
+ garbage, unusable URL. The regex now requires a real image extension and
21
+ only matches valid URL characters, so it stops exactly at the image URL.
22
+ - Image/video extraction now tries Pinterest's embedded page-state JSON
23
+ (`<script id="__PWS_DATA__">`) first via a new `Extractors::PinData`
24
+ module, which is far more reliable than scraping meta tags/regex and is
25
+ immune to the CSS-bleed issue above; the old scraping strategies remain as
26
+ a fallback if a page doesn't embed that data.
27
+
6
28
  ## [2.0.0]
7
29
 
8
30
  ### Added
data/lib/pinterest-dl.rb CHANGED
@@ -1,12 +1,14 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  # Monji024
4
+ require 'json'
4
5
  require_relative 'pinterest_dl/version'
5
6
  require_relative 'pinterest_dl/errors'
6
7
  require_relative 'pinterest_dl/configuration'
7
8
  require_relative 'pinterest_dl/client'
8
9
  require_relative 'pinterest_dl/extractors/image'
9
10
  require_relative 'pinterest_dl/extractors/video'
11
+ require_relative 'pinterest_dl/extractors/pin_data'
10
12
  require_relative 'pinterest_dl/search'
11
13
  require_relative 'pinterest_dl/board'
12
14
  require_relative 'pinterest_dl/downloader'
@@ -2,7 +2,7 @@
2
2
 
3
3
  # Monji024
4
4
  module PinterestDL
5
- PIN_URL_PATTERN = %r{pinterest\.[a-z.]+/pin/}i.freeze
5
+ PIN_URL_PATTERN = %r{(pinterest\.[a-z.]+/pin/|pin\.it/)}i.freeze
6
6
 
7
7
  class << self
8
8
  def config
@@ -21,7 +21,7 @@ module PinterestDL
21
21
  def get_image_url(pin_url, quality: config.quality, client: Client.new(config: config))
22
22
  validate_pin_url!(pin_url)
23
23
  html = client.get(pin_url)
24
- url = Extractors::Image.extract(html, quality: quality)
24
+ url = extract_pin_image(html, quality: quality)
25
25
  raise NotFoundError, "No image found at #{pin_url}" unless url
26
26
 
27
27
  url
@@ -30,18 +30,18 @@ module PinterestDL
30
30
  def get_video_url(pin_url, client: Client.new(config: config))
31
31
  validate_pin_url!(pin_url)
32
32
  html = client.get(pin_url)
33
- url = Extractors::Video.extract(html)
33
+ url = extract_pin_video(html)
34
34
  raise NotFoundError, "No video found at #{pin_url}" unless url
35
35
 
36
36
  url
37
37
  end
38
38
 
39
- def get_media(pin_url, quality: config.quality)
40
- client = Client.new(config: config)
39
+ def get_media(pin_url, quality: config.quality, client: Client.new(config: config))
40
+ validate_pin_url!(pin_url)
41
41
  html = client.get(pin_url)
42
42
  {
43
- image_url: Extractors::Image.extract(html, quality: quality),
44
- video_url: Extractors::Video.extract(html)
43
+ image_url: extract_pin_image(html, quality: quality),
44
+ video_url: extract_pin_video(html)
45
45
  }
46
46
  end
47
47
 
@@ -75,6 +75,20 @@ module PinterestDL
75
75
 
76
76
  private
77
77
 
78
+ def extract_pin_image(html, quality:)
79
+ pin = Extractors::PinData.extract(html)
80
+ url = pin && pin[:image_url]
81
+ url ||= Extractors::Image.extract(html, quality: quality)
82
+ return nil unless url
83
+
84
+ Extractors::Image.apply_quality(url, quality)
85
+ end
86
+
87
+ def extract_pin_video(html)
88
+ pin = Extractors::PinData.extract(html)
89
+ (pin && pin[:video_url]) || Extractors::Video.extract(html)
90
+ end
91
+
78
92
  def validate_pin_url!(pin_url)
79
93
  return if pin_url.to_s.match?(PIN_URL_PATTERN)
80
94
 
@@ -1,40 +1,37 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'json'
4
- require 'net/http'
5
4
  require 'uri'
6
5
 
7
6
  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/'
7
+ class Board
8
+ B_URL = 'https://www.pinterest.com/resource/BoardFeedResource/get/'
9
+
10
+ BOARD_URL_PATTERN = %r{pinterest\.[a-z.]+/(?<username>[^/]+)/(?<slug>[^/]+)/?(?<section>[^/]+)?/?\z}
12
11
 
13
12
  def initialize(client: PinterestDL::Client.new, config: PinterestDL.config)
14
13
  @client = client
15
14
  @config = config
16
15
  end
17
16
 
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)
17
+ def call(board_url, limit: 50, pages: 10)
18
+ match = BOARD_URL_PATTERN.match(board_url)
19
+ raise PinterestDL::InvalidURLError, "Not a Pinterest board URL: #{board_url}" unless match
20
+
23
21
  results = []
24
22
  bookmarks = nil
25
23
  pages_fetched = 0
26
24
 
27
25
  loop do
28
- page = fetch_page(query, bookmarks: bookmarks)
29
- pins = page[:pins]
30
- results.concat(pins)
26
+ page = fetch_page(match, bookmarks: bookmarks)
27
+ results.concat(page[:pins])
31
28
  bookmarks = page[:bookmarks]
32
29
  pages_fetched += 1
33
30
 
34
31
  break if results.size >= limit
35
32
  break if bookmarks.nil? || bookmarks.empty? || bookmarks == ['-end-']
36
33
  break if pages_fetched >= pages
37
- break if pins.empty?
34
+ break if page[:pins].empty?
38
35
  end
39
36
 
40
37
  results.first(limit)
@@ -42,40 +39,36 @@ module PinterestDL
42
39
 
43
40
  private
44
41
 
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
42
+ def fetch_page(match, bookmarks:)
43
+ body = @client.get(build_request_url(match, bookmarks), use_cache: false, headers: request_headers)
44
+ json = safe_parse(body)
45
+ raise PinterestDL::NotFoundError, "Board not found: #{match[0]}" unless json
57
46
 
58
- res_uri = URI(RESOURCE_URL)
59
- res_uri.query = URI.encode_www_form('source_url' => source_path, 'data' => data)
47
+ data_node = json.dig('resource_response', 'data') || []
48
+ next_bookmarks = json.dig('resource_response', 'bookmark')
60
49
 
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
- })
50
+ { pins: Array(data_node).filter_map { |r| build_pin(r) }, bookmarks: Array(next_bookmarks) }
51
+ end
69
52
 
70
- json = safe_parse(body)
71
- raise PinterestDL::NotFoundError, "No search results for \"#{query}\"" unless json
53
+ def build_request_url(match, bookmarks)
54
+ options = {
55
+ board_url: "/#{match[:username]}/#{match[:slug]}/",
56
+ board_id: '',
57
+ section_slug: match[:section]
58
+ }.compact
59
+ options[:bookmarks] = bookmarks if bookmarks && !bookmarks.empty?
72
60
 
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')
61
+ data = { options: options, context: {} }.to_json
62
+ res_uri = URI(B_URL)
63
+ res_uri.query = URI.encode_www_form('source_url' => options[:board_url], 'data' => data)
64
+ res_uri.to_s
65
+ end
77
66
 
78
- { pins: raw_results.filter_map { |r| build_pin(r) }, bookmarks: Array(next_bookmarks) }
67
+ def request_headers
68
+ {
69
+ 'Accept' => 'application/json, text/javascript, */*, q=0.01',
70
+ 'X-Requested-With' => 'XMLHttpRequest'
71
+ }
79
72
  end
80
73
 
81
74
  def build_pin(raw)
@@ -83,8 +76,7 @@ module PinterestDL
83
76
  return nil unless id
84
77
 
85
78
  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')
79
+ video_url = first_video_url(raw)
88
80
 
89
81
  {
90
82
  id: id,
@@ -92,11 +84,18 @@ module PinterestDL
92
84
  title: raw['title'] || raw['grid_title'],
93
85
  description: raw['description'],
94
86
  creator: raw.dig('pinner', 'username') || raw.dig('pinner', 'full_name'),
95
- image_url: orig_image || fallback_image,
87
+ image_url: orig_image,
96
88
  video_url: video_url
97
89
  }
98
90
  end
99
91
 
92
+ def first_video_url(raw)
93
+ video_list = raw.dig('videos', 'video_list')
94
+ return nil unless video_list
95
+
96
+ video_list.values.first&.dig('url')
97
+ end
98
+
100
99
  def safe_parse(body)
101
100
  JSON.parse(body)
102
101
  rescue JSON::ParserError, TypeError
@@ -71,7 +71,14 @@ module PinterestDL
71
71
  raise PinterestDL::NotFoundError, "Redirected without a location header (#{url})" unless location
72
72
 
73
73
  location = URI.join(url, location).to_s if location.start_with?('/')
74
- get_or_raw(location, raw: raw)
74
+
75
+ uri = URI(location)
76
+ if uri.host&.match?(/^(?:www\.)?pinterest\.(?!com$)[a-z.]+$/i) || uri.host == 'pin.it'
77
+ uri.host = 'www.pinterest.com'
78
+ location = uri.to_s
79
+ end
80
+
81
+ get_or_raw(location, raw: raw)
75
82
  when Net::HTTPForbidden
76
83
  raise PinterestDL::RateLimitError, "Request to #{url} was blocked (HTTP 403)"
77
84
  else
@@ -30,7 +30,9 @@ module PinterestDL
30
30
  end
31
31
 
32
32
  def from_raw_pattern(html)
33
- match = html.match(%r{https://i\.pinimg\.com/(?:originals|736x|474x|236x)/[^"'\s\\]+})
33
+ match = html.match(
34
+ %r{https://i\.pinimg\.com/(?:originals|736x|474x|236x)/[A-Za-z0-9_\-/]+\.(?:jpg|jpeg|png|gif|webp)}i
35
+ )
34
36
  match && match[0]
35
37
  end
36
38
 
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+
5
+ module PinterestDL
6
+ module Extractors
7
+ module PinData
8
+ SCRIPT_PATTERN = %r{<script[^>]*id=["']__PWS_DATA__["'][^>]*>(.*?)</script>}m.freeze
9
+ module_function
10
+ def extract(html)
11
+ data = parse_embedded_json(html)
12
+ return nil unless data
13
+
14
+ pin = find_pin_node(data)
15
+ return nil unless pin
16
+ {
17
+ image_url: pin.dig('images', 'orig', 'url'),
18
+ video_url: first_video_url(pin),
19
+ title: pin['title'] || pin['grid_title'],
20
+ description: pin['description']
21
+ }
22
+ end
23
+
24
+ def parse_embedded_json(html)
25
+ html.to_s.scan(SCRIPT_PATTERN).each do |(raw)|
26
+ parsed = safe_parse(raw)
27
+ return parsed if parsed
28
+ end
29
+ nil
30
+ end
31
+
32
+ def find_pin_node(data)
33
+ stack = [data]
34
+ until stack.empty?
35
+ node = stack.pop
36
+ case node
37
+ when Hash
38
+ return node if pin_like?(node)
39
+
40
+ stack.concat(node.values)
41
+ when Array
42
+ stack.concat(node)
43
+ end
44
+ end
45
+ nil
46
+ end
47
+
48
+ def pin_like?(node)
49
+ images = node['images']
50
+ images.is_a?(Hash) && images.dig('orig', 'url')
51
+ end
52
+
53
+ def first_video_url(pin)
54
+ video_list = pin.dig('videos', 'video_list')
55
+ return nil unless video_list.is_a?(Hash)
56
+
57
+ video_list.values.first&.dig('url')
58
+ end
59
+
60
+ def safe_parse(raw)
61
+ JSON.parse(raw)
62
+ rescue JSON::ParserError
63
+ nil
64
+ end
65
+ end
66
+ end
67
+ end
@@ -33,8 +33,6 @@ module PinterestDL
33
33
  end
34
34
  end
35
35
 
36
- # Shared helper for both extractors: pulls a `contentUrl` out of an
37
- # `application/ld+json` <script> tag matching the given @type.
38
36
  module JsonLd
39
37
  module_function
40
38
 
@@ -6,7 +6,7 @@ require 'uri'
6
6
 
7
7
  module PinterestDL
8
8
  class Search
9
- RESOURCE_URL = 'https://www.pinterest.com/resource/BaseSearchResource/get/'
9
+ S_URL = 'https://www.pinterest.com/resource/BaseSearchResource/get/'
10
10
 
11
11
  def initialize(client: PinterestDL::Client.new, config: PinterestDL.config)
12
12
  @client = client
@@ -50,6 +50,7 @@ module PinterestDL
50
50
  parse_search_response(json)
51
51
  end
52
52
 
53
+
53
54
  def establish_session(query)
54
55
  source_path = "/search/pins/?q=#{URI.encode_www_form_component(query)}"
55
56
  home_uri = URI("https://www.pinterest.com#{source_path}")
@@ -69,7 +70,7 @@ module PinterestDL
69
70
  options[:bookmarks] = bookmarks if bookmarks && !bookmarks.empty?
70
71
  data = { options: options, context: {} }.to_json
71
72
 
72
- res_uri = URI(RESOURCE_URL)
73
+ res_uri = URI(S_URL)
73
74
  res_uri.query = URI.encode_www_form('source_url' => source_path, 'data' => data)
74
75
  res_uri.to_s
75
76
  end
@@ -91,7 +92,7 @@ module PinterestDL
91
92
  data_node = json.dig('resource_response', 'data') || {}
92
93
  raw_results = data_node['results'] || []
93
94
  next_bookmarks = json.dig('resource_response', 'bookmark') ||
94
- json.dig('resource', 'options', 'bookmarks')
95
+ json.dig('resource', 'options', 'bookmarks')
95
96
 
96
97
  { pins: raw_results.filter_map { |r| build_pin(r) }, bookmarks: Array(next_bookmarks) }
97
98
  end
@@ -2,5 +2,5 @@
2
2
 
3
3
  # Monji024
4
4
  module PinterestDL
5
- VERSION = '2.0.0'
5
+ VERSION = '2.0.3'
6
6
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: pinterest-dl
3
3
  version: !ruby/object:Gem::Version
4
- version: 2.0.0
4
+ version: 2.0.3
5
5
  platform: ruby
6
6
  authors:
7
7
  - Monji
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-08-28 00:00:00.000000000 Z
11
+ date: 2026-08-29 00:00:00.000000000 Z
12
12
  dependencies: []
13
13
  description: A Ruby toolkit for getting direct image and video URLs from Pinterest
14
14
  pins, searching pins, fetching board contents, and downloading media to disk — as
@@ -31,6 +31,7 @@ files:
31
31
  - lib/pinterest_dl/downloader.rb
32
32
  - lib/pinterest_dl/errors.rb
33
33
  - lib/pinterest_dl/extractors/image.rb
34
+ - lib/pinterest_dl/extractors/pin_data.rb
34
35
  - lib/pinterest_dl/extractors/video.rb
35
36
  - lib/pinterest_dl/progress_bar.rb
36
37
  - lib/pinterest_dl/search.rb