pinterest-dl 2.0.1 → 2.0.4

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: ab49a0f8fc2500835b07ec8abf935c4a643d7a9fa8f6e2de9bdb5250f10ef24c
4
+ data.tar.gz: 9fb506ca599f3a1625989ab178c7ed4f0c0e5ddad236f416f514666c90741bf0
5
5
  SHA512:
6
- metadata.gz: 4e26a226009b81da00d41f46f9ccfcdca2f058aa7ff93210b488e1f2082c27f927103f5a0329dcea921ea34a62c49c3ad70c77d411d4a1b569b4b97fbba8c4cf
7
- data.tar.gz: 66b3ddf6d10555f9e018198a3de376578b22fc504dfe386d8a49f06bdcc213519e0b273c5f123d34bcb33eb3217da51af7609712a2f1a0a3b67004be6858eb9b
6
+ metadata.gz: e3962e2ea256111c1c9178947a0aa026daec6fed0c1cfefb2f81d92dd77299eebc9c2634f2ecf9e74ede3edfacb327ce28381c9aeffa951f9960c81f8883dddd
7
+ data.tar.gz: '09407f8541a4042be42584393524b8aee0147654d46c0abdc1d0c17ad9467bbaa17406fd260099fe214db43aa946553f20a696524616e66e2817477d6cd897b8'
data/CHANGELOG.md CHANGED
@@ -3,6 +3,33 @@
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.3]
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
+ - `PinterestDL.board` requests were missing a session cookie and CSRF token,
15
+ which Pinterest's internal resource API requires — every board request
16
+ came back as HTTP 403. `Board` now loads the board page first to obtain
17
+ a session cookie + CSRF token before calling the resource API, the same
18
+ way `Search` already did.
19
+ - `pin.it/...` short links were rejected by `get_image_url` / `get_video_url` /
20
+ `get_media` even though Pinterest itself redirects them to a normal pin
21
+ page. `PIN_URL_PATTERN` now accepts `pin.it/...` links too.
22
+ - The raw-HTML fallback regex used to find an image when no `og:image` or
23
+ JSON-LD tag was present could "bleed" past the actual image URL into
24
+ trailing inline CSS (e.g. `...img.png)}._YsBbF{border:0...`), producing a
25
+ garbage, unusable URL. The regex now requires a real image extension and
26
+ only matches valid URL characters, so it stops exactly at the image URL.
27
+ - Image/video extraction now tries Pinterest's embedded page-state JSON
28
+ (`<script id="__PWS_DATA__">`) first via a new `Extractors::PinData`
29
+ module, which is far more reliable than scraping meta tags/regex and is
30
+ immune to the CSS-bleed issue above; the old scraping strategies remain as
31
+ a fallback if a page doesn't embed that data.
32
+
6
33
  ## [2.0.0]
7
34
 
8
35
  ### 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
 
@@ -74,6 +74,19 @@ module PinterestDL
74
74
  end
75
75
 
76
76
  private
77
+ def extract_pin_image(html, quality:)
78
+ pin = Extractors::PinData.extract(html)
79
+ url = pin && pin[:image_url]
80
+ url ||= Extractors::Image.extract(html, quality: quality)
81
+ return nil unless url
82
+
83
+ Extractors::Image.apply_quality(url, quality)
84
+ end
85
+
86
+ def extract_pin_video(html)
87
+ pin = Extractors::PinData.extract(html)
88
+ (pin && pin[:video_url]) || Extractors::Video.extract(html)
89
+ end
77
90
 
78
91
  def validate_pin_url!(pin_url)
79
92
  return if pin_url.to_s.match?(PIN_URL_PATTERN)
@@ -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,62 @@ 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
+ session = establish_session(match)
44
+ body = @client.get(
45
+ build_request_url(match, bookmarks),
46
+ use_cache: false,
47
+ headers: board_headers(session)
48
+ )
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
+ json = safe_parse(body)
51
+ raise PinterestDL::NotFoundError, "Board not found: #{match[0]}" unless json
48
52
 
49
- options = { query: query, scope: 'pins', page_size: 25 }
50
- options[:bookmarks] = bookmarks if bookmarks && !bookmarks.empty?
51
- data = { options: options, context: {} }.to_json
53
+ data_node = json.dig('resource_response', 'data') || []
54
+ next_bookmarks = json.dig('resource_response', 'bookmark')
52
55
 
53
- res_uri = URI(B_URL)
54
- res_uri.query = URI.encode_www_form('source_url' => source_path, 'data' => data)
56
+ { pins: Array(data_node).filter_map { |r| build_pin(r) }, bookmarks: Array(next_bookmarks) }
57
+ end
55
58
 
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
- })
59
+ def establish_session(match)
60
+ board_path = "/#{match[:username]}/#{match[:slug]}/"
61
+ board_uri = URI("https://www.pinterest.com#{board_path}")
62
+ home_response = @client.raw_get(board_uri.to_s)
63
+ cookies = (home_response.get_fields('set-cookie') || []).map { |c| c.split(';').first }
64
64
 
65
- json = safe_parse(body)
66
- raise PinterestDL::NotFoundError, "No search results for \"#{query}\"" unless json
65
+ { board_path: board_path, board_uri: board_uri, cookies: cookies, csrf: csrf_token(cookies) }
66
+ end
67
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')
68
+ def csrf_token(cookies)
69
+ token_cookie = cookies.find { |c| c.start_with?('csrftoken=') }
70
+ token_cookie ? token_cookie.split('=', 2).last : 'undefined'
71
+ end
72
+
73
+ def build_request_url(match, bookmarks)
74
+ options = {
75
+ board_url: "/#{match[:username]}/#{match[:slug]}/",
76
+ board_id: '',
77
+ section_slug: match[:section]
78
+ }.compact
79
+ options[:bookmarks] = bookmarks if bookmarks && !bookmarks.empty?
72
80
 
73
- { pins: raw_results.filter_map { |r| build_pin(r) }, bookmarks: Array(next_bookmarks) }
81
+ data = { options: options, context: {} }.to_json
82
+ res_uri = URI(B_URL)
83
+ res_uri.query = URI.encode_www_form('source_url' => options[:board_url], 'data' => data)
84
+ res_uri.to_s
85
+ end
86
+
87
+ def board_headers(session)
88
+ cookie_header = [@config.cookies, session[:cookies].join('; ')].compact.reject(&:empty?).join('; ')
89
+
90
+ {
91
+ 'Accept' => 'application/json, text/javascript, */*, q=0.01',
92
+ 'X-Requested-With' => 'XMLHttpRequest',
93
+ 'X-Pinterest-PWS-Handler' => 'www/[username]/[slug].js',
94
+ 'X-CSRFToken' => session[:csrf],
95
+ 'Referer' => session[:board_uri].to_s,
96
+ 'Cookie' => cookie_header
97
+ }
74
98
  end
75
99
 
76
100
  def build_pin(raw)
@@ -78,8 +102,7 @@ module PinterestDL
78
102
  return nil unless id
79
103
 
80
104
  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')
105
+ video_url = first_video_url(raw)
83
106
 
84
107
  {
85
108
  id: id,
@@ -87,11 +110,18 @@ module PinterestDL
87
110
  title: raw['title'] || raw['grid_title'],
88
111
  description: raw['description'],
89
112
  creator: raw.dig('pinner', 'username') || raw.dig('pinner', 'full_name'),
90
- image_url: orig_image || fallback_image,
113
+ image_url: orig_image,
91
114
  video_url: video_url
92
115
  }
93
116
  end
94
117
 
118
+ def first_video_url(raw)
119
+ video_list = raw.dig('videos', 'video_list')
120
+ return nil unless video_list
121
+
122
+ video_list.values.first&.dig('url')
123
+ end
124
+
95
125
  def safe_parse(body)
96
126
  JSON.parse(body)
97
127
  rescue JSON::ParserError, TypeError
@@ -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,70 @@
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
+
10
+ module_function
11
+
12
+ def extract(html)
13
+ data = parse_embedded_json(html)
14
+ return nil unless data
15
+
16
+ pin = find_pin_node(data)
17
+ return nil unless pin
18
+
19
+ {
20
+ image_url: pin.dig('images', 'orig', 'url'),
21
+ video_url: first_video_url(pin),
22
+ title: pin['title'] || pin['grid_title'],
23
+ description: pin['description']
24
+ }
25
+ end
26
+
27
+ def parse_embedded_json(html)
28
+ html.to_s.scan(SCRIPT_PATTERN).each do |(raw)|
29
+ parsed = safe_parse(raw)
30
+ return parsed if parsed
31
+ end
32
+ nil
33
+ end
34
+
35
+ def find_pin_node(data)
36
+ stack = [data]
37
+ until stack.empty?
38
+ node = stack.pop
39
+ case node
40
+ when Hash
41
+ return node if pin_like?(node)
42
+
43
+ stack.concat(node.values)
44
+ when Array
45
+ stack.concat(node)
46
+ end
47
+ end
48
+ nil
49
+ end
50
+
51
+ def pin_like?(node)
52
+ images = node['images']
53
+ images.is_a?(Hash) && images.dig('orig', 'url')
54
+ end
55
+
56
+ def first_video_url(pin)
57
+ video_list = pin.dig('videos', 'video_list')
58
+ return nil unless video_list.is_a?(Hash)
59
+
60
+ video_list.values.first&.dig('url')
61
+ end
62
+
63
+ def safe_parse(raw)
64
+ JSON.parse(raw)
65
+ rescue JSON::ParserError
66
+ nil
67
+ end
68
+ end
69
+ end
70
+ end
@@ -3,7 +3,6 @@
3
3
  # Monji024
4
4
  module PinterestDL
5
5
  module Extractors
6
- # Extracts the direct video URL for a pin from raw HTML.
7
6
  module Video
8
7
  module_function
9
8
 
@@ -33,8 +32,6 @@ module PinterestDL
33
32
  end
34
33
  end
35
34
 
36
- # Shared helper for both extractors: pulls a `contentUrl` out of an
37
- # `application/ld+json` <script> tag matching the given @type.
38
35
  module JsonLd
39
36
  module_function
40
37
 
@@ -2,5 +2,5 @@
2
2
 
3
3
  # Monji024
4
4
  module PinterestDL
5
- VERSION = '2.0.1'
5
+ VERSION = '2.0.4'
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.4
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-30 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