pinterest-dl 2.0.4 → 2.1.5

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: ab49a0f8fc2500835b07ec8abf935c4a643d7a9fa8f6e2de9bdb5250f10ef24c
4
- data.tar.gz: 9fb506ca599f3a1625989ab178c7ed4f0c0e5ddad236f416f514666c90741bf0
3
+ metadata.gz: 51b7122dcca9ae653e0f63a70665fa8b5dfddd87a9d7e17eeef3462843ed90c1
4
+ data.tar.gz: e82c0d139c5bf84dfd5e13fa96488d8013636f72ce7e2bdc5bc9a8efc49151cc
5
5
  SHA512:
6
- metadata.gz: e3962e2ea256111c1c9178947a0aa026daec6fed0c1cfefb2f81d92dd77299eebc9c2634f2ecf9e74ede3edfacb327ce28381c9aeffa951f9960c81f8883dddd
7
- data.tar.gz: '09407f8541a4042be42584393524b8aee0147654d46c0abdc1d0c17ad9467bbaa17406fd260099fe214db43aa946553f20a696524616e66e2817477d6cd897b8'
6
+ metadata.gz: 911490799bd9db4405781306091bbe06271413e25b834190d46394ad7b288be5ce3153ba25d9261122dbb4efe0be08188c49583751999d08e87be223a017300e
7
+ data.tar.gz: f18ffdcc24defdbf6bb392f738ed6b6dc62047ccecacfb90eb90fb52a879b18dd84c7677288d538f8105ecae7e611faec6470a0dfeaedb07f8767d9e822c0e20
data/CHANGELOG.md CHANGED
@@ -3,6 +3,39 @@
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.1.5]
7
+
8
+ ### Fixed
9
+ - Pin URLs on country domains (e.g. `de.pinterest.com/pin/...`) didn't download
10
+ while `pin.it` links did. Pin URLs are now normalised to
11
+ `www.pinterest.com/pin/ID/`, and the HTTP client follows redirects itself
12
+ (max 5) with a small cookie jar, so a redirect loop can no longer happen.
13
+ - `Client#raw_get` returned the first redirect response instead of the final
14
+ one, which lost the session cookie / CSRF token.
15
+ - Pin extraction could pick a related pin instead of the pin you asked for.
16
+ The pin is now matched by its id.
17
+ - Video pins returned "No video found". The best `.mp4` is picked now (not just
18
+ the first format, which could be an `.m3u8`), story pins are supported, and
19
+ `og:video` meta tags are read in any attribute order.
20
+ - `video` worked only some of the time: Pinterest sometimes serves a lighter
21
+ page without the video data. Now every `__PWS_*__` data block of the page is
22
+ read, then the pin is asked from the `PinResource` API (with the
23
+ `X-Pinterest-PWS-Handler` header Pinterest needs, otherwise it answers 403),
24
+ and the page is fetched fresh up to 3 times before giving up. The "this is an
25
+ image" hint only shows when Pinterest really returned an image pin.
26
+ - `board` reads the board id from the page first and only asks the API when it
27
+ isn't there. Story modules ("Related interests") are no longer treated as pins.
28
+ - `board` never worked: Pinterest needs the real board id, we sent an empty
29
+ one. The id is now looked up first, the session is created once instead of
30
+ once per page, links with `?invite_code=...` are parsed correctly, and
31
+ `pin.it` links work. Sections use `BoardSectionPinsResource`.
32
+
33
+ ### Added
34
+ - `--type all|image|video|pin` for `search` and `board`
35
+ (`PinterestDL.search(..., type: :video)`, `PinterestDL.board(..., type: :image)`).
36
+ `pin` prints the pin links (saved to `pins.txt` with `-o`).
37
+ - With `-o` and type `all`, video pins are now saved as videos, not thumbnails.
38
+
6
39
  ## [2.0.3]
7
40
 
8
41
  ### Fixed
@@ -64,4 +97,4 @@ This project follows [Semantic Versioning](https://semver.org/).
64
97
  ## [1.0.3]
65
98
 
66
99
  - Initial public release: `get_image_url`, `get_video_url`, `search`,
67
- `search_json`, `search_images`, `search_videos`.
100
+ `search_json`, `search_images`, `search_videos`.
data/README.md CHANGED
@@ -160,10 +160,13 @@ pinterest-dl video https://www.pinterest.com/pin/example/ -o pin.mp4
160
160
  pinterest-dl search "cyberpunk city" -n 20
161
161
  pinterest-dl search "cyberpunk city" -n 20 -o ./downloads
162
162
  pinterest-dl board https://www.pinterest.com/username/board-name/ -n 50 -o ./downloads
163
+ pinterest-dl board https://www.pinterest.com/username/board-name/ -t video -o ./videos
164
+ pinterest-dl search "cats" -t image -n 30 -o ./images
165
+ pinterest-dl search "cats" -t pin -n 30
163
166
  pinterest-dl --help
164
167
  ```
165
168
 
166
- Flags: `-o/--output`, `-n/--limit`, `-q/--quality`, `-c/--cookies`, `-v/--verbose`.
169
+ Flags: `-o/--output`, `-n/--limit`, `-t/--type` (`all`, `image`, `video`, `pin`), `-q/--quality`, `-c/--cookies`, `-v/--verbose`.
167
170
 
168
171
 
169
172
  ## Development
data/bin/pinterest-dl CHANGED
@@ -10,26 +10,26 @@ def usage
10
10
  Usage:
11
11
  pinterest-dl image URL [-o FILE]
12
12
  pinterest-dl video URL [-o FILE]
13
- pinterest-dl search QUERY [-n LIMIT] [-o DIR]
14
- pinterest-dl board URL [-n LIMIT] [-o DIR]
13
+ pinterest-dl search QUERY [-n LIMIT] [-t TYPE] [-o DIR]
14
+ pinterest-dl board URL [-n LIMIT] [-t TYPE] [-o DIR]
15
15
 
16
16
  Options:
17
17
  -o, --output PATH File (image/video) or directory (search/board) to save to
18
18
  -n, --limit N Max number of results (search/board), default 25
19
+ -t, --type TYPE What to collect in search/board: all, image, video, pin (default all)
19
20
  -q, --quality Q Image quality: originals, 736x, 474x, 236x (default originals)
20
21
  -c, --cookies STR Cookie string for authenticated requests
21
- -v, --verbose Print debug logging
22
22
  -h, --help Show this help
23
23
  USAGE
24
24
  end
25
25
 
26
- options = { limit: 25, quality: :originals }
26
+ options = { limit: 25, quality: :originals, type: :all }
27
27
  parser = OptionParser.new do |opts|
28
28
  opts.on('-o PATH', '--output PATH') { |v| options[:output] = v }
29
29
  opts.on('-n N', '--limit N', Integer) { |v| options[:limit] = v }
30
+ opts.on('-t T', '--type T') { |v| options[:type] = v.to_sym }
30
31
  opts.on('-q Q', '--quality Q') { |v| options[:quality] = v.to_sym }
31
32
  opts.on('-c STR', '--cookies STR') { |v| options[:cookies] = v }
32
- opts.on('-v', '--verbose') { options[:verbose] = true }
33
33
  opts.on('-h', '--help') do
34
34
  puts usage
35
35
  exit 0
@@ -61,6 +61,31 @@ def die(message)
61
61
  exit 1
62
62
  end
63
63
 
64
+ def handle_pins(pins, options)
65
+ if pins.empty?
66
+ puts 'No matching pins found'
67
+ return
68
+ end
69
+
70
+ if options[:type] == :pin
71
+ links = pins.map { |p| p[:pin_url] }
72
+ if options[:output]
73
+ FileUtils.mkdir_p(options[:output])
74
+ File.write(File.join(options[:output], 'pins.txt'), "#{links.join("\n")}\n")
75
+ puts "Saved #{links.size} pin links to #{options[:output]}/pins.txt"
76
+ else
77
+ puts links
78
+ end
79
+ elsif options[:output]
80
+ urls = pins.filter_map { |p| p[:video_url] || p[:image_url] }
81
+ bar = PinterestDL::ProgressBar.new(total: urls.size)
82
+ result = PinterestDL.download_batch(urls, dir: options[:output]) { |done, _total, _| bar.update(done) }
83
+ puts "Downloaded #{result[:succeeded].size}/#{urls.size} files to #{options[:output]}"
84
+ else
85
+ puts JSON.pretty_generate(pins)
86
+ end
87
+ end
88
+
64
89
  begin
65
90
  case command
66
91
  when 'image'
@@ -80,28 +105,12 @@ begin
80
105
  puts url
81
106
  end
82
107
  when 'search'
83
- pins = PinterestDL.search(target, limit: options[:limit])
84
- if options[:output]
85
- urls = pins.filter_map { |p| p[:image_url] }
86
- bar = PinterestDL::ProgressBar.new(total: urls.size)
87
- result = PinterestDL.download_batch(urls, dir: options[:output]) { |done, total, _| bar.update(done) }
88
- puts "Downloaded #{result[:succeeded].size}/#{urls.size} images to #{options[:output]}"
89
- else
90
- puts JSON.pretty_generate(pins)
91
- end
108
+ handle_pins(PinterestDL.search(target, limit: options[:limit], type: options[:type]), options)
92
109
  when 'board'
93
- pins = PinterestDL.board(target, limit: options[:limit])
94
- if options[:output]
95
- urls = pins.filter_map { |p| p[:image_url] }
96
- bar = PinterestDL::ProgressBar.new(total: urls.size)
97
- result = PinterestDL.download_batch(urls, dir: options[:output]) { |done, total, _| bar.update(done) }
98
- puts "Downloaded #{result[:succeeded].size}/#{urls.size} images to #{options[:output]}"
99
- else
100
- puts JSON.pretty_generate(pins)
101
- end
110
+ handle_pins(PinterestDL.board(target, limit: options[:limit], type: options[:type]), options)
102
111
  else
103
112
  die "unknown command '#{command}'\n\n#{usage}"
104
113
  end
105
- rescue PinterestDL::Error => e
114
+ rescue PinterestDL::Error, ArgumentError => e
106
115
  die e.message
107
116
  end
data/lib/pinterest-dl.rb CHANGED
@@ -6,6 +6,7 @@ require_relative 'pinterest_dl/version'
6
6
  require_relative 'pinterest_dl/errors'
7
7
  require_relative 'pinterest_dl/configuration'
8
8
  require_relative 'pinterest_dl/client'
9
+ require_relative 'pinterest_dl/media_type'
9
10
  require_relative 'pinterest_dl/extractors/image'
10
11
  require_relative 'pinterest_dl/extractors/video'
11
12
  require_relative 'pinterest_dl/extractors/pin_data'
@@ -19,29 +19,34 @@ module PinterestDL
19
19
  end
20
20
 
21
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 = extract_pin_image(html, quality: quality)
22
+ html, pin_id = load_pin(pin_url, client)
23
+ url = extract_pin_image(html, pin_id, quality: quality)
24
+ url ||= api_image_url(pin_id, client, quality)
25
25
  raise NotFoundError, "No image found at #{pin_url}" unless url
26
26
 
27
27
  url
28
28
  end
29
29
 
30
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 = extract_pin_video(html)
34
- raise NotFoundError, "No video found at #{pin_url}" unless url
31
+ pin = nil
32
+ 3.times do |attempt|
33
+ html, pin_id = load_pin(pin_url, client, fresh: attempt.positive?)
34
+ url = extract_pin_video(html, pin_id)
35
+ return url if url
35
36
 
36
- url
37
+ pin = pin_from_api(pin_id, client)
38
+ return pin[:video_url] if pin && pin[:video_url]
39
+ end
40
+
41
+ hint = pin && pin[:image_url] ? ' (it looks like an image pin, try the image command)' : ''
42
+ raise NotFoundError, "No video found at #{pin_url}#{hint}"
37
43
  end
38
44
 
39
45
  def get_media(pin_url, quality: config.quality, client: Client.new(config: config))
40
- validate_pin_url!(pin_url)
41
- html = client.get(pin_url)
46
+ html, pin_id = load_pin(pin_url, client)
42
47
  {
43
- image_url: extract_pin_image(html, quality: quality),
44
- video_url: extract_pin_video(html)
48
+ image_url: extract_pin_image(html, pin_id, quality: quality),
49
+ video_url: extract_pin_video(html, pin_id)
45
50
  }
46
51
  end
47
52
 
@@ -53,8 +58,10 @@ module PinterestDL
53
58
  Downloader.new(config: config).download_batch(urls, dir: dir, filename: filename, &progress)
54
59
  end
55
60
 
56
- def search(query, limit: 25, pages: 5)
57
- Search.new(config: config).call(query, limit: limit, pages: pages)
61
+ def search(query, limit: 25, pages: 5, type: :all)
62
+ type = MediaType.parse(type)
63
+ pages = [pages, 15].max unless type == :all
64
+ Search.new(config: config).call(query, limit: limit, pages: pages, type: type)
58
65
  end
59
66
 
60
67
  def search_json(query, limit: 25)
@@ -62,20 +69,69 @@ module PinterestDL
62
69
  end
63
70
 
64
71
  def search_images(query, limit: 25)
65
- search(query, limit: limit).filter_map { |p| p[:image_url] }
72
+ search(query, limit: limit, type: :image).filter_map { |p| p[:image_url] }
66
73
  end
67
74
 
68
75
  def search_videos(query, limit: 25)
69
- search(query, limit: limit).filter_map { |p| p[:video_url] }
76
+ search(query, limit: limit, type: :video).filter_map { |p| p[:video_url] }
70
77
  end
71
78
 
72
- def board(board_url, limit: 50, pages: 10)
73
- Board.new(config: config).call(board_url, limit: limit, pages: pages)
79
+ def board(board_url, limit: 50, pages: 10, type: :all)
80
+ type = MediaType.parse(type)
81
+ pages = [pages, 20].max unless type == :all
82
+ Board.new(config: config).call(board_url, limit: limit, pages: pages, type: type)
74
83
  end
75
84
 
76
85
  private
77
- def extract_pin_image(html, quality:)
78
- pin = Extractors::PinData.extract(html)
86
+
87
+ def load_pin(pin_url, client, fresh: false)
88
+ validate_pin_url!(pin_url)
89
+ html = client.get(normalize_pin_url(pin_url), use_cache: !fresh)
90
+ id = pin_id_from(client.last_url) || pin_id_from(pin_url) || html.to_s[%r{pinterest\.[a-z.]+/pin/(\d+)}, 1]
91
+ [html, id]
92
+ end
93
+
94
+ def pin_from_api(pin_id, client)
95
+ return nil unless pin_id
96
+
97
+ data = { options: { id: pin_id, field_set_key: 'detailed' }, context: {} }.to_json
98
+ uri = URI('https://www.pinterest.com/resource/PinResource/get/')
99
+ uri.query = URI.encode_www_form('source_url' => "/pin/#{pin_id}/", 'data' => data)
100
+ headers = {
101
+ 'Accept' => 'application/json, text/javascript, */*, q=0.01',
102
+ 'X-Requested-With' => 'XMLHttpRequest',
103
+ 'X-Pinterest-PWS-Handler' => 'www/pin/[id].js',
104
+ 'X-Pinterest-Source-Url' => "/pin/#{pin_id}/",
105
+ 'X-CSRFToken' => client.cookie('csrftoken') || 'undefined',
106
+ 'Referer' => "https://www.pinterest.com/pin/#{pin_id}/"
107
+ }
108
+
109
+ pin = JSON.parse(client.get(uri.to_s, headers: headers, use_cache: false)).dig('resource_response', 'data')
110
+ return nil unless pin.is_a?(Hash)
111
+
112
+ { image_url: pin.dig('images', 'orig', 'url'), video_url: Extractors::PinData.first_video_url(pin) }
113
+ rescue PinterestDL::Error, JSON::ParserError => e
114
+ logger.debug("PinResource failed for pin #{pin_id}: #{e.message}")
115
+ nil
116
+ end
117
+
118
+ def api_image_url(pin_id, client, quality)
119
+ url = pin_from_api(pin_id, client)&.dig(:image_url)
120
+ url && Extractors::Image.apply_quality(url, quality)
121
+ end
122
+
123
+ def normalize_pin_url(pin_url)
124
+ id = pin_id_from(pin_url)
125
+ id ? "https://www.pinterest.com/pin/#{id}/" : pin_url
126
+ end
127
+
128
+ def pin_id_from(url)
129
+ match = url.to_s.match(%r{/pin/(?:[^/?#]*--)?(\d+)})
130
+ match && match[1]
131
+ end
132
+
133
+ def extract_pin_image(html, pin_id, quality:)
134
+ pin = Extractors::PinData.extract(html, pin_id)
79
135
  url = pin && pin[:image_url]
80
136
  url ||= Extractors::Image.extract(html, quality: quality)
81
137
  return nil unless url
@@ -83,8 +139,8 @@ module PinterestDL
83
139
  Extractors::Image.apply_quality(url, quality)
84
140
  end
85
141
 
86
- def extract_pin_video(html)
87
- pin = Extractors::PinData.extract(html)
142
+ def extract_pin_video(html, pin_id)
143
+ pin = Extractors::PinData.extract(html, pin_id)
88
144
  (pin && pin[:video_url]) || Extractors::Video.extract(html)
89
145
  end
90
146
 
@@ -5,31 +5,31 @@ require 'uri'
5
5
 
6
6
  module PinterestDL
7
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}
8
+ PAGE_SIZE = 50
11
9
 
12
10
  def initialize(client: PinterestDL::Client.new, config: PinterestDL.config)
13
11
  @client = client
14
12
  @config = config
15
13
  end
16
14
 
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
15
+ def call(board_url, limit: 50, pages: 10, type: :all)
16
+ username, slug, section = parse_url(board_url)
17
+ html = start_session(username, slug)
18
+ board_id = board_id_from_page(html, slug) || find_board_id(username, slug)
19
+ resource, options = feed_request(board_id, section)
20
20
 
21
21
  results = []
22
22
  bookmarks = nil
23
23
  pages_fetched = 0
24
24
 
25
25
  loop do
26
- page = fetch_page(match, bookmarks: bookmarks)
27
- results.concat(page[:pins])
26
+ page = fetch_page(resource, options, bookmarks)
27
+ results.concat(PinterestDL::MediaType.filter(page[:pins], type))
28
28
  bookmarks = page[:bookmarks]
29
29
  pages_fetched += 1
30
30
 
31
31
  break if results.size >= limit
32
- break if bookmarks.nil? || bookmarks.empty? || bookmarks == ['-end-']
32
+ break if bookmarks.empty? || bookmarks == ['-end-']
33
33
  break if pages_fetched >= pages
34
34
  break if page[:pins].empty?
35
35
  end
@@ -39,93 +39,135 @@ module PinterestDL
39
39
 
40
40
  private
41
41
 
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
- )
42
+ def parse_url(board_url)
43
+ url = board_url.to_s
44
+ if url.include?('pin.it/')
45
+ @client.raw_get(url)
46
+ url = @client.last_url
47
+ end
49
48
 
50
- json = safe_parse(body)
51
- raise PinterestDL::NotFoundError, "Board not found: #{match[0]}" unless json
49
+ uri = URI(url)
50
+ parts = uri.path.to_s.split('/').reject(&:empty?)
51
+ valid = uri.host.to_s.include?('pinterest') && [2, 3].include?(parts.size) && parts.first != 'pin'
52
+ raise PinterestDL::InvalidURLError, "Not a Pinterest board URL: #{board_url}" unless valid
52
53
 
53
- data_node = json.dig('resource_response', 'data') || []
54
- next_bookmarks = json.dig('resource_response', 'bookmark')
54
+ parts
55
+ rescue URI::InvalidURIError
56
+ raise PinterestDL::InvalidURLError, "Not a Pinterest board URL: #{board_url}"
57
+ end
55
58
 
56
- { pins: Array(data_node).filter_map { |r| build_pin(r) }, bookmarks: Array(next_bookmarks) }
59
+ def start_session(username, slug)
60
+ response = @client.raw_get("https://www.pinterest.com/#{username}/#{slug}/")
61
+ @page_url = @client.last_url
62
+ @host = URI(@page_url).host
63
+ @source_path = URI(@page_url).path
64
+ response.body
57
65
  end
58
66
 
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 }
67
+ def board_id_from_page(html, slug)
68
+ html.to_s.scan(PinterestDL::Extractors::PinData::SCRIPT_PATTERN).each do |(raw)|
69
+ data = PinterestDL::Extractors::PinData.safe_parse(raw)
70
+ stack = [data]
71
+ until stack.empty?
72
+ node = stack.pop
73
+ case node
74
+ when Hash
75
+ return node['id'].to_s if board_node?(node, slug)
76
+
77
+ stack.concat(node.values)
78
+ when Array
79
+ stack.concat(node)
80
+ end
81
+ end
82
+ end
83
+ nil
84
+ end
64
85
 
65
- { board_path: board_path, board_uri: board_uri, cookies: cookies, csrf: csrf_token(cookies) }
86
+ def board_node?(node, slug)
87
+ node['type'] == 'board' && node['id'].to_s.match?(/\A\d+\z/) && node['url'].to_s.include?("/#{slug}/")
66
88
  end
67
89
 
68
- def csrf_token(cookies)
69
- token_cookie = cookies.find { |c| c.start_with?('csrftoken=') }
70
- token_cookie ? token_cookie.split('=', 2).last : 'undefined'
90
+ def find_board_id(username, slug)
91
+ json = call_resource('BoardResource', username: username, slug: slug, field_set_key: 'detailed')
92
+ data = json.dig('resource_response', 'data')
93
+ id = data.is_a?(Hash) && data['id']
94
+ raise PinterestDL::NotFoundError, "Board not found: #{username}/#{slug} (private boards need --cookies)" unless id
95
+
96
+ id
71
97
  end
72
98
 
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?
99
+ def feed_request(board_id, section)
100
+ unless section
101
+ options = {
102
+ board_id: board_id,
103
+ field_set_key: 'react_grid_pin',
104
+ filter_section_pins: true,
105
+ sort: 'default',
106
+ layout: 'default',
107
+ page_size: PAGE_SIZE,
108
+ redux_normalize_feed: true
109
+ }
110
+ return ['BoardFeedResource', options]
111
+ end
80
112
 
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
113
+ ['BoardSectionPinsResource', { section_id: find_section_id(board_id, section), page_size: PAGE_SIZE }]
114
+ end
115
+
116
+ def find_section_id(board_id, slug)
117
+ json = call_resource('BoardSectionsResource', board_id: board_id)
118
+ data = json.dig('resource_response', 'data')
119
+ found = data.is_a?(Array) && data.find { |s| s['slug'] == slug }
120
+ raise PinterestDL::NotFoundError, "Section not found: #{slug}" unless found
121
+
122
+ found['id']
123
+ end
124
+
125
+ def fetch_page(resource, options, bookmarks)
126
+ options = options.merge(bookmarks: bookmarks) if bookmarks && !bookmarks.empty?
127
+ json = call_resource(resource, options)
128
+
129
+ data = json.dig('resource_response', 'data')
130
+ pins = data.is_a?(Array) ? data.filter_map { |r| build_pin(r) } : []
131
+ { pins: pins, bookmarks: Array(json.dig('resource_response', 'bookmark')) }
85
132
  end
86
133
 
87
- def board_headers(session)
88
- cookie_header = [@config.cookies, session[:cookies].join('; ')].compact.reject(&:empty?).join('; ')
134
+ def call_resource(name, options)
135
+ data = { options: options, context: {} }.to_json
136
+ uri = URI("https://#{@host}/resource/#{name}/get/")
137
+ uri.query = URI.encode_www_form('source_url' => @source_path, 'data' => data)
138
+
139
+ body = @client.get(uri.to_s, use_cache: false, headers: api_headers)
140
+ JSON.parse(body)
141
+ rescue JSON::ParserError, TypeError
142
+ raise PinterestDL::NotFoundError, "Unexpected response from Pinterest (#{name})"
143
+ end
89
144
 
145
+ def api_headers
90
146
  {
91
147
  'Accept' => 'application/json, text/javascript, */*, q=0.01',
92
148
  'X-Requested-With' => 'XMLHttpRequest',
93
149
  'X-Pinterest-PWS-Handler' => 'www/[username]/[slug].js',
94
- 'X-CSRFToken' => session[:csrf],
95
- 'Referer' => session[:board_uri].to_s,
96
- 'Cookie' => cookie_header
150
+ 'X-CSRFToken' => @client.cookie('csrftoken', @page_url) || 'undefined',
151
+ 'Referer' => @page_url
97
152
  }
98
153
  end
99
154
 
100
155
  def build_pin(raw)
156
+ return nil unless raw.is_a?(Hash)
157
+ return nil if raw['type'] && raw['type'] != 'pin'
158
+
101
159
  id = raw['id']
102
160
  return nil unless id
103
161
 
104
- orig_image = raw.dig('images', 'orig', 'url')
105
- video_url = first_video_url(raw)
106
-
107
162
  {
108
163
  id: id,
109
164
  pin_url: "https://www.pinterest.com/pin/#{id}/",
110
165
  title: raw['title'] || raw['grid_title'],
111
166
  description: raw['description'],
112
167
  creator: raw.dig('pinner', 'username') || raw.dig('pinner', 'full_name'),
113
- image_url: orig_image,
114
- video_url: video_url
168
+ image_url: raw.dig('images', 'orig', 'url'),
169
+ video_url: PinterestDL::Extractors::PinData.first_video_url(raw)
115
170
  }
116
171
  end
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
-
125
- def safe_parse(body)
126
- JSON.parse(body)
127
- rescue JSON::ParserError, TypeError
128
- nil
129
- end
130
172
  end
131
173
  end
@@ -7,26 +7,36 @@ require 'openssl'
7
7
 
8
8
  module PinterestDL
9
9
  class Client
10
+ MAX_REDIRECTS = 5
11
+
12
+ attr_reader :last_url
13
+
10
14
  def initialize(config: PinterestDL.config)
11
15
  @config = config
12
16
  @cache = {}
17
+ @cookies = Hash.new { |hash, key| hash[key] = {} }
13
18
  @last_request_at = nil
19
+ @last_url = nil
14
20
  end
15
21
 
16
22
  def get(url, headers: {}, use_cache: true)
17
23
  cache_key = [url, headers].hash
18
- return @cache[cache_key] if use_cache && @cache.key?(cache_key)
24
+ if use_cache && @cache.key?(cache_key)
25
+ body, @last_url = @cache[cache_key]
26
+ return body
27
+ end
19
28
 
20
- response = request_with_retries(url, headers: headers)
21
- body = handle_response(response, url)
22
- @cache[cache_key] = body if use_cache
29
+ body = fetch(url, headers: headers).body
30
+ @cache[cache_key] = [body, @last_url] if use_cache
23
31
  body
24
32
  end
25
33
 
26
34
  def raw_get(url, headers: {})
27
- response = request_with_retries(url, headers: headers)
28
- handle_response(response, url, raw: true)
29
- response
35
+ fetch(url, headers: headers)
36
+ end
37
+
38
+ def cookie(name, url = @last_url)
39
+ @cookies[site_key(URI(url))][name]
30
40
  end
31
41
 
32
42
  def clear_cache
@@ -35,13 +45,32 @@ module PinterestDL
35
45
 
36
46
  private
37
47
 
48
+ def fetch(url, headers: {})
49
+ (MAX_REDIRECTS + 1).times do
50
+ response = request_with_retries(url, headers: headers)
51
+ save_cookies(response, url)
52
+
53
+ unless response.is_a?(Net::HTTPRedirection)
54
+ check_response(response, url)
55
+ @last_url = url
56
+ return response
57
+ end
58
+
59
+ location = response['location']
60
+ raise PinterestDL::NotFoundError, "Redirected without a location header (#{url})" unless location
61
+
62
+ url = URI.join(url, location).to_s
63
+ end
64
+ raise PinterestDL::NetworkError, "Too many redirects for #{url}"
65
+ end
66
+
38
67
  def request_with_retries(url, headers: {})
39
68
  attempts = 0
40
69
  begin
41
70
  throttle!
42
71
  uri = URI(url)
43
72
  http = build_http(uri)
44
- req = Net::HTTP::Get.new(uri, default_headers.merge(headers))
73
+ req = Net::HTTP::Get.new(uri, default_headers(uri).merge(headers))
45
74
  http.request(req)
46
75
  rescue Timeout::Error, Errno::ECONNRESET, Errno::ECONNREFUSED,
47
76
  SocketError, OpenSSL::SSL::SSLError, EOFError => e
@@ -55,10 +84,10 @@ module PinterestDL
55
84
  end
56
85
  end
57
86
 
58
- def handle_response(response, url, raw: false)
87
+ def check_response(response, url)
59
88
  case response
60
89
  when Net::HTTPSuccess
61
- raw ? response : response.body
90
+ response
62
91
  when Net::HTTPTooManyRequests
63
92
  raise PinterestDL::RateLimitError.new(
64
93
  "Rate limited while requesting #{url}",
@@ -66,12 +95,6 @@ module PinterestDL
66
95
  )
67
96
  when Net::HTTPNotFound
68
97
  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
98
  when Net::HTTPForbidden
76
99
  raise PinterestDL::RateLimitError, "Request to #{url} was blocked (HTTP 403)"
77
100
  else
@@ -79,10 +102,6 @@ module PinterestDL
79
102
  end
80
103
  end
81
104
 
82
- def get_or_raw(url, raw:)
83
- raw ? raw_get(url) : get(url)
84
- end
85
-
86
105
  def build_http(uri)
87
106
  http = Net::HTTP.new(uri.host, uri.port)
88
107
  http.use_ssl = (uri.scheme == 'https')
@@ -91,12 +110,37 @@ module PinterestDL
91
110
  http
92
111
  end
93
112
 
94
- def default_headers
95
- headers = { 'User-Agent' => @config.user_agent }
96
- headers['Cookie'] = @config.cookies if @config.authenticated?
113
+ def default_headers(uri)
114
+ headers = {
115
+ 'User-Agent' => @config.user_agent,
116
+ 'Accept-Language' => 'en-US,en;q=0.9'
117
+ }
118
+ cookie_line = cookie_header(uri)
119
+ headers['Cookie'] = cookie_line unless cookie_line.empty?
97
120
  headers
98
121
  end
99
122
 
123
+ def cookie_header(uri)
124
+ parts = []
125
+ parts << @config.cookies.to_s if @config.authenticated?
126
+ parts.concat(@cookies[site_key(uri)].map { |name, value| "#{name}=#{value}" })
127
+ parts.join('; ')
128
+ end
129
+
130
+ def save_cookies(response, url)
131
+ jar = @cookies[site_key(URI(url))]
132
+ (response.get_fields('set-cookie') || []).each do |line|
133
+ name, value = line.split(';').first.to_s.split('=', 2)
134
+ next if name.nil? || value.nil?
135
+
136
+ value.empty? ? jar.delete(name.strip) : jar[name.strip] = value
137
+ end
138
+ end
139
+
140
+ def site_key(uri)
141
+ uri.host.to_s.split('.').last(2).join('.')
142
+ end
143
+
100
144
  def throttle!
101
145
  return if @config.rate_limit_interval <= 0
102
146
  return unless @last_request_at
@@ -25,8 +25,7 @@ module PinterestDL
25
25
  end
26
26
 
27
27
  def from_open_graph(html)
28
- match = html.match(/<meta[^>]*property=["']og:image["'][^>]*content=["']([^"']+)["']/)
29
- match && match[1]
28
+ PinterestDL::Extractors::Meta.content(html, 'og:image')
30
29
  end
31
30
 
32
31
  def from_raw_pattern(html)
@@ -5,17 +5,26 @@ require 'json'
5
5
  module PinterestDL
6
6
  module Extractors
7
7
  module PinData
8
- SCRIPT_PATTERN = %r{<script[^>]*id=["']__PWS_DATA__["'][^>]*>(.*?)</script>}m.freeze
8
+ SCRIPT_PATTERN = %r{<script[^>]*id=["']__PWS_[A-Z_]+__["'][^>]*>(.*?)</script>}m.freeze
9
9
 
10
10
  module_function
11
11
 
12
- def extract(html)
13
- data = parse_embedded_json(html)
14
- return nil unless data
12
+ def extract(html, pin_id = nil)
13
+ found = nil
14
+ html.to_s.scan(SCRIPT_PATTERN).each do |(raw)|
15
+ data = safe_parse(raw)
16
+ pin = data && find_pin_node(data, pin_id)
17
+ next unless pin
18
+
19
+ result = build_result(pin)
20
+ return result if result[:video_url]
15
21
 
16
- pin = find_pin_node(data)
17
- return nil unless pin
22
+ found ||= result
23
+ end
24
+ found
25
+ end
18
26
 
27
+ def build_result(pin)
19
28
  {
20
29
  image_url: pin.dig('images', 'orig', 'url'),
21
30
  video_url: first_video_url(pin),
@@ -32,20 +41,24 @@ module PinterestDL
32
41
  nil
33
42
  end
34
43
 
35
- def find_pin_node(data)
44
+ def find_pin_node(data, pin_id = nil)
45
+ matches = []
36
46
  stack = [data]
37
47
  until stack.empty?
38
48
  node = stack.pop
39
49
  case node
40
50
  when Hash
41
- return node if pin_like?(node)
42
-
51
+ if pin_id
52
+ matches << node if node['id'].to_s == pin_id.to_s && has_media?(node)
53
+ elsif pin_like?(node)
54
+ return node
55
+ end
43
56
  stack.concat(node.values)
44
57
  when Array
45
58
  stack.concat(node)
46
59
  end
47
60
  end
48
- nil
61
+ matches.find { |n| first_video_url(n) } || matches.first
49
62
  end
50
63
 
51
64
  def pin_like?(node)
@@ -53,11 +66,50 @@ module PinterestDL
53
66
  images.is_a?(Hash) && images.dig('orig', 'url')
54
67
  end
55
68
 
69
+ def has_media?(node)
70
+ node['images'].is_a?(Hash) || node['videos'].is_a?(Hash) || node['story_pin_data'].is_a?(Hash)
71
+ end
72
+
56
73
  def first_video_url(pin)
57
74
  video_list = pin.dig('videos', 'video_list')
75
+ video_list = deep_find(pin['story_pin_data'], 'video_list') unless video_list.is_a?(Hash)
76
+ best_video(video_list)
77
+ end
78
+
79
+ def best_video(video_list)
58
80
  return nil unless video_list.is_a?(Hash)
59
81
 
60
- video_list.values.first&.dig('url')
82
+ formats = video_list.values.select { |v| v.is_a?(Hash) && v['url'] }
83
+ mp4s = formats.select { |v| v['url'].include?('.mp4') }
84
+ best = mp4s.max_by { |v| v['width'].to_i * v['height'].to_i }
85
+ return best['url'] if best
86
+
87
+ hls = formats.find { |v| v['url'].include?('.m3u8') }
88
+ hls && hls_to_mp4(hls['url'])
89
+ end
90
+
91
+ def hls_to_mp4(url)
92
+ if url.include?('/iht/hls/')
93
+ url.sub('/hls/', '/expMp4/').sub(/\.m3u8.*\z/, '_720w.mp4')
94
+ else
95
+ url.sub('/hls/', '/720p/').sub(/\.m3u8.*\z/, '.mp4')
96
+ end
97
+ end
98
+
99
+ def deep_find(node, key)
100
+ stack = [node]
101
+ until stack.empty?
102
+ current = stack.pop
103
+ case current
104
+ when Hash
105
+ return current[key] if current[key].is_a?(Hash)
106
+
107
+ stack.concat(current.values)
108
+ when Array
109
+ stack.concat(current)
110
+ end
111
+ end
112
+ nil
61
113
  end
62
114
 
63
115
  def safe_parse(raw)
@@ -7,7 +7,7 @@ module PinterestDL
7
7
  module_function
8
8
 
9
9
  def extract(html)
10
- url = from_json_ld(html) || from_open_graph(html) || from_content_url(html)
10
+ url = from_json_ld(html) || from_open_graph(html) || from_content_url(html) || from_raw_pattern(html)
11
11
  return nil unless url
12
12
 
13
13
  unescape(url)
@@ -18,8 +18,7 @@ module PinterestDL
18
18
  end
19
19
 
20
20
  def from_open_graph(html)
21
- match = html.match(/<meta[^>]*property=["']og:video["'][^>]*content=["']([^"']+)["']/)
22
- match && match[1]
21
+ PinterestDL::Extractors::Meta.content(html, 'og:video')
23
22
  end
24
23
 
25
24
  def from_content_url(html)
@@ -27,11 +26,31 @@ module PinterestDL
27
26
  match && match[1]
28
27
  end
29
28
 
29
+ def from_raw_pattern(html)
30
+ clean = html.gsub('\\u002F', '/').gsub('\\/', '/')
31
+ match = clean.match(%r{https://v\d*\.pinimg\.com/videos/[^"'\s\\<>]+?\.mp4})
32
+ match && match[0]
33
+ end
34
+
30
35
  def unescape(url)
31
36
  url.gsub('\\u002F', '/').gsub('\\', '')
32
37
  end
33
38
  end
34
39
 
40
+ module Meta
41
+ module_function
42
+
43
+ def content(html, key)
44
+ html.to_s.scan(/<meta\b[^>]*>/i).each do |tag|
45
+ next unless tag.match?(/(?:property|name)=["']#{Regexp.escape(key)}["']/i)
46
+
47
+ match = tag.match(/content=["']([^"']+)["']/i)
48
+ return match[1] if match
49
+ end
50
+ nil
51
+ end
52
+ end
53
+
35
54
  module JsonLd
36
55
  module_function
37
56
 
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Monji024
4
+ module PinterestDL
5
+ module MediaType
6
+ TYPES = %i[all image video pin].freeze
7
+
8
+ module_function
9
+
10
+ def parse(value)
11
+ type = (value || :all).to_sym
12
+ return type if TYPES.include?(type)
13
+
14
+ raise ArgumentError, "type must be one of: #{TYPES.join(', ')}"
15
+ end
16
+
17
+ def filter(pins, type)
18
+ case parse(type)
19
+ when :image then pins.select { |p| p[:image_url] && !p[:video_url] }
20
+ when :video then pins.select { |p| p[:video_url] }
21
+ else pins
22
+ end
23
+ end
24
+ end
25
+ end
@@ -13,7 +13,7 @@ module PinterestDL
13
13
  @config = config
14
14
  end
15
15
 
16
- def call(query, limit: 25, pages: 5)
16
+ def call(query, limit: 25, pages: 5, type: :all)
17
17
  results = []
18
18
  bookmarks = nil
19
19
  pages_fetched = 0
@@ -21,7 +21,7 @@ module PinterestDL
21
21
  loop do
22
22
  page = fetch_page(query, bookmarks: bookmarks)
23
23
  pins = page[:pins]
24
- results.concat(pins)
24
+ results.concat(PinterestDL::MediaType.filter(pins, type))
25
25
  bookmarks = page[:bookmarks]
26
26
  pages_fetched += 1
27
27
 
@@ -49,9 +49,6 @@ module PinterestDL
49
49
 
50
50
  parse_search_response(json)
51
51
  end
52
-
53
- # Pinterest's search resource requires a session cookie + CSRF token
54
- # obtained by first loading the human-facing search page.
55
52
  def establish_session(query)
56
53
  source_path = "/search/pins/?q=#{URI.encode_www_form_component(query)}"
57
54
  home_uri = URI("https://www.pinterest.com#{source_path}")
@@ -129,10 +126,7 @@ module PinterestDL
129
126
  end
130
127
 
131
128
  def first_video_url(raw)
132
- video_list = raw.dig('videos', 'video_list')
133
- return nil unless video_list
134
-
135
- video_list.values.first&.dig('url')
129
+ PinterestDL::Extractors::PinData.first_video_url(raw)
136
130
  end
137
131
 
138
132
  def safe_parse(body)
@@ -2,5 +2,5 @@
2
2
 
3
3
  # Monji024
4
4
  module PinterestDL
5
- VERSION = '2.0.4'
5
+ VERSION = '2.1.5'
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.4
4
+ version: 2.1.5
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-30 00:00:00.000000000 Z
11
+ date: 2026-10-14 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
@@ -33,6 +33,7 @@ files:
33
33
  - lib/pinterest_dl/extractors/image.rb
34
34
  - lib/pinterest_dl/extractors/pin_data.rb
35
35
  - lib/pinterest_dl/extractors/video.rb
36
+ - lib/pinterest_dl/media_type.rb
36
37
  - lib/pinterest_dl/progress_bar.rb
37
38
  - lib/pinterest_dl/search.rb
38
39
  - lib/pinterest_dl/version.rb
@@ -60,7 +61,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
60
61
  - !ruby/object:Gem::Version
61
62
  version: '0'
62
63
  requirements: []
63
- rubygems_version: 3.3.15
64
+ rubygems_version: 3.4.20
64
65
  signing_key:
65
66
  specification_version: 4
66
67
  summary: Extract direct image/video URLs, search, and download from Pinterest