pinterest-dl 2.0.1 → 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: b4d94f0a5bbac63a983308d541b5e50a6a13c1d5bee422c356e56395c686ebcd
4
- data.tar.gz: 148473c309ec313a3e0d7ddb0864f0388d22a2bf9edee39e819e54ccaab10745
3
+ metadata.gz: 2ba41b3cdd44c6463cfffde77fc4b9db8185f47b1d62e3a9555b9408bfd4f197
4
+ data.tar.gz: b032e121ffb9750ed128b0a0c6ce5d0266dea8db4fbc99ee1bd6579e8f680d1a
5
5
  SHA512:
6
- metadata.gz: 4e26a226009b81da00d41f46f9ccfcdca2f058aa7ff93210b488e1f2082c27f927103f5a0329dcea921ea34a62c49c3ad70c77d411d4a1b569b4b97fbba8c4cf
7
- data.tar.gz: 66b3ddf6d10555f9e018198a3de376578b22fc504dfe386d8a49f06bdcc213519e0b273c5f123d34bcb33eb3217da51af7609712a2f1a0a3b67004be6858eb9b
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,35 +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
- class Search
9
- B_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}
10
11
 
11
12
  def initialize(client: PinterestDL::Client.new, config: PinterestDL.config)
12
13
  @client = client
13
14
  @config = config
14
15
  end
15
16
 
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
16
20
 
17
- def call(query, limit: 25, pages: 5)
18
21
  results = []
19
22
  bookmarks = nil
20
23
  pages_fetched = 0
21
24
 
22
25
  loop do
23
- page = fetch_page(query, bookmarks: bookmarks)
24
- pins = page[:pins]
25
- results.concat(pins)
26
+ page = fetch_page(match, bookmarks: bookmarks)
27
+ results.concat(page[:pins])
26
28
  bookmarks = page[:bookmarks]
27
29
  pages_fetched += 1
28
30
 
29
31
  break if results.size >= limit
30
32
  break if bookmarks.nil? || bookmarks.empty? || bookmarks == ['-end-']
31
33
  break if pages_fetched >= pages
32
- break if pins.empty?
34
+ break if page[:pins].empty?
33
35
  end
34
36
 
35
37
  results.first(limit)
@@ -37,40 +39,36 @@ module PinterestDL
37
39
 
38
40
  private
39
41
 
40
- def fetch_page(query, bookmarks: nil)
41
- source_path = "/search/pins/?q=#{URI.encode_www_form_component(query)}"
42
- home_uri = URI("https://www.pinterest.com#{source_path}")
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
46
+
47
+ data_node = json.dig('resource_response', 'data') || []
48
+ next_bookmarks = json.dig('resource_response', 'bookmark')
43
49
 
44
- home_response = @client.raw_get(home_uri.to_s)
45
- session_cookies = (home_response.get_fields('set-cookie') || []).map { |c| c.split(';').first }
46
- cookie_header = [@config.cookies, session_cookies.join('; ')].compact.reject(&:empty?).join('; ')
47
- csrf = session_cookies.find { |c| c.start_with?('csrftoken=') }&.split('=', 2)&.last || 'undefined'
50
+ { pins: Array(data_node).filter_map { |r| build_pin(r) }, bookmarks: Array(next_bookmarks) }
51
+ end
48
52
 
49
- options = { query: query, scope: 'pins', page_size: 25 }
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
50
59
  options[:bookmarks] = bookmarks if bookmarks && !bookmarks.empty?
51
- data = { options: options, context: {} }.to_json
52
60
 
61
+ data = { options: options, context: {} }.to_json
53
62
  res_uri = URI(B_URL)
54
- res_uri.query = URI.encode_www_form('source_url' => source_path, 'data' => data)
55
-
56
- body = @client.get(res_uri.to_s, use_cache: false, headers: {
57
- 'Accept' => 'application/json, text/javascript, */*, q=0.01',
58
- 'X-Requested-With' => 'XMLHttpRequest',
59
- 'X-Pinterest-PWS-Handler' => 'www/search/[scope].js',
60
- 'X-CSRFToken' => csrf,
61
- 'Referer' => home_uri.to_s,
62
- 'Cookie' => cookie_header
63
- })
64
-
65
- json = safe_parse(body)
66
- raise PinterestDL::NotFoundError, "No search results for \"#{query}\"" unless json
67
-
68
- data_node = json.dig('resource_response', 'data') || {}
69
- raw_results = data_node['results'] || []
70
- next_bookmarks = json.dig('resource_response', 'bookmark') ||
71
- json.dig('resource', 'options', 'bookmarks')
63
+ res_uri.query = URI.encode_www_form('source_url' => options[:board_url], 'data' => data)
64
+ res_uri.to_s
65
+ end
72
66
 
73
- { 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
+ }
74
72
  end
75
73
 
76
74
  def build_pin(raw)
@@ -78,8 +76,7 @@ module PinterestDL
78
76
  return nil unless id
79
77
 
80
78
  orig_image = raw.dig('images', 'orig', 'url')
81
- fallback_image = raw['images']&.values&.map { |v| v.is_a?(Hash) ? v['url'] : nil }&.compact&.last
82
- video_url = raw.dig('videos', 'video_list')&.values&.first&.dig('url')
79
+ video_url = first_video_url(raw)
83
80
 
84
81
  {
85
82
  id: id,
@@ -87,11 +84,18 @@ module PinterestDL
87
84
  title: raw['title'] || raw['grid_title'],
88
85
  description: raw['description'],
89
86
  creator: raw.dig('pinner', 'username') || raw.dig('pinner', 'full_name'),
90
- image_url: orig_image || fallback_image,
87
+ image_url: orig_image,
91
88
  video_url: video_url
92
89
  }
93
90
  end
94
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
+
95
99
  def safe_parse(body)
96
100
  JSON.parse(body)
97
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
 
@@ -50,8 +50,7 @@ module PinterestDL
50
50
  parse_search_response(json)
51
51
  end
52
52
 
53
- # Pinterest's search resource requires a session cookie + CSRF token
54
- # obtained by first loading the human-facing search page.
53
+
55
54
  def establish_session(query)
56
55
  source_path = "/search/pins/?q=#{URI.encode_www_form_component(query)}"
57
56
  home_uri = URI("https://www.pinterest.com#{source_path}")
@@ -2,5 +2,5 @@
2
2
 
3
3
  # Monji024
4
4
  module PinterestDL
5
- VERSION = '2.0.1'
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.1
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