pinterest-dl 2.0.3 → 2.0.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: 2ba41b3cdd44c6463cfffde77fc4b9db8185f47b1d62e3a9555b9408bfd4f197
4
- data.tar.gz: b032e121ffb9750ed128b0a0c6ce5d0266dea8db4fbc99ee1bd6579e8f680d1a
3
+ metadata.gz: d14e37c46d9bf7d3b8851c433a430da6811a040151e1103912b300985569b038
4
+ data.tar.gz: f19617c2661b7cb970206eabe8a6fb1471dfa9045f09ebf2e0393bfebdc83e5c
5
5
  SHA512:
6
- metadata.gz: 11fe875c2a1eabda28fb7f7b4f342b33565dc141b5fd7ff06cac800595a834c9156abd9fe61544f75eed944f0553fc180183cad5ac7fdef6ca13a03023e179ae
7
- data.tar.gz: dc3600f15608c7717f2010cceef198e636e423b3788ee5763ff4e49fdfd19f49cef545e5206d1a7a0981d658194a97745aecbc0f373da038062f381b2ad74ec6
6
+ metadata.gz: 8ac5b7a9d1525a24dee57a7857cc345d8febb84061cb82b018d507c35c03d04e2d214cc108584dcfb56208d6f05f524a74fa427faa9ffd1b25e34314ab597eae
7
+ data.tar.gz: 565a098363fbf28418ef5312972bf2e4dec08d8ade89cad70a59c425bc1a25f052c56eb4c335e48b15237097db9487c833384d4ede470acd685cec986ed98e82
data/CHANGELOG.md CHANGED
@@ -3,7 +3,32 @@
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]
6
+ ## [2.0.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
+ - `board` never worked: Pinterest needs the real board id, we sent an empty
21
+ one. The id is now looked up first, the session is created once instead of
22
+ once per page, links with `?invite_code=...` are parsed correctly, and
23
+ `pin.it` links work. Sections use `BoardSectionPinsResource`.
24
+
25
+ ### Added
26
+ - `--type all|image|video|pin` for `search` and `board`
27
+ (`PinterestDL.search(..., type: :video)`, `PinterestDL.board(..., type: :image)`).
28
+ `pin` prints the pin links (saved to `pins.txt` with `-o`).
29
+ - With `-o` and type `all`, video pins are now saved as videos, not thumbnails.
30
+
31
+ ## [2.0.3]
7
32
 
8
33
  ### Fixed
9
34
  - **`PinterestDL::Board` was broken.** `lib/pinterest_dl/board.rb` accidentally
@@ -11,6 +36,11 @@ This project follows [Semantic Versioning](https://semver.org/).
11
36
  search logic), so it silently collided with `PinterestDL::Search` at load
12
37
  time and `PinterestDL.board(...)` never worked correctly. Restored the
13
38
  correct `Board` class backed by Pinterest's `BoardFeedResource`.
39
+ - `PinterestDL.board` requests were missing a session cookie and CSRF token,
40
+ which Pinterest's internal resource API requires — every board request
41
+ came back as HTTP 403. `Board` now loads the board page first to obtain
42
+ a session cookie + CSRF token before calling the resource API, the same
43
+ way `Search` already did.
14
44
  - `pin.it/...` short links were rejected by `get_image_url` / `get_video_url` /
15
45
  `get_media` even though Pinterest itself redirects them to a normal pin
16
46
  page. `PIN_URL_PATTERN` now accepts `pin.it/...` links too.
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,12 +10,13 @@ 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
22
  -v, --verbose Print debug logging
@@ -23,10 +24,11 @@ def usage
23
24
  USAGE
24
25
  end
25
26
 
26
- options = { limit: 25, quality: :originals }
27
+ options = { limit: 25, quality: :originals, type: :all }
27
28
  parser = OptionParser.new do |opts|
28
29
  opts.on('-o PATH', '--output PATH') { |v| options[:output] = v }
29
30
  opts.on('-n N', '--limit N', Integer) { |v| options[:limit] = v }
31
+ opts.on('-t T', '--type T') { |v| options[:type] = v.to_sym }
30
32
  opts.on('-q Q', '--quality Q') { |v| options[:quality] = v.to_sym }
31
33
  opts.on('-c STR', '--cookies STR') { |v| options[:cookies] = v }
32
34
  opts.on('-v', '--verbose') { options[:verbose] = true }
@@ -61,6 +63,31 @@ def die(message)
61
63
  exit 1
62
64
  end
63
65
 
66
+ def handle_pins(pins, options)
67
+ if pins.empty?
68
+ puts 'No matching pins found'
69
+ return
70
+ end
71
+
72
+ if options[:type] == :pin
73
+ links = pins.map { |p| p[:pin_url] }
74
+ if options[:output]
75
+ FileUtils.mkdir_p(options[:output])
76
+ File.write(File.join(options[:output], 'pins.txt'), "#{links.join("\n")}\n")
77
+ puts "Saved #{links.size} pin links to #{options[:output]}/pins.txt"
78
+ else
79
+ puts links
80
+ end
81
+ elsif options[:output]
82
+ urls = pins.filter_map { |p| p[:video_url] || p[:image_url] }
83
+ bar = PinterestDL::ProgressBar.new(total: urls.size)
84
+ result = PinterestDL.download_batch(urls, dir: options[:output]) { |done, _total, _| bar.update(done) }
85
+ puts "Downloaded #{result[:succeeded].size}/#{urls.size} files to #{options[:output]}"
86
+ else
87
+ puts JSON.pretty_generate(pins)
88
+ end
89
+ end
90
+
64
91
  begin
65
92
  case command
66
93
  when 'image'
@@ -80,28 +107,12 @@ begin
80
107
  puts url
81
108
  end
82
109
  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
110
+ handle_pins(PinterestDL.search(target, limit: options[:limit], type: options[:type]), options)
92
111
  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
112
+ handle_pins(PinterestDL.board(target, limit: options[:limit], type: options[:type]), options)
102
113
  else
103
114
  die "unknown command '#{command}'\n\n#{usage}"
104
115
  end
105
- rescue PinterestDL::Error => e
116
+ rescue PinterestDL::Error, ArgumentError => e
106
117
  die e.message
107
118
  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,29 @@ 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)
25
24
  raise NotFoundError, "No image found at #{pin_url}" unless url
26
25
 
27
26
  url
28
27
  end
29
28
 
30
29
  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
30
+ html, pin_id = load_pin(pin_url, client)
31
+ url = extract_pin_video(html, pin_id)
32
+ unless url
33
+ hint = extract_pin_image(html, pin_id, quality: :originals) ? ' (this pin is an image, try the image command)' : ''
34
+ raise NotFoundError, "No video found at #{pin_url}#{hint}"
35
+ end
35
36
 
36
37
  url
37
38
  end
38
39
 
39
40
  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)
41
+ html, pin_id = load_pin(pin_url, client)
42
42
  {
43
- image_url: extract_pin_image(html, quality: quality),
44
- video_url: extract_pin_video(html)
43
+ image_url: extract_pin_image(html, pin_id, quality: quality),
44
+ video_url: extract_pin_video(html, pin_id)
45
45
  }
46
46
  end
47
47
 
@@ -53,8 +53,10 @@ module PinterestDL
53
53
  Downloader.new(config: config).download_batch(urls, dir: dir, filename: filename, &progress)
54
54
  end
55
55
 
56
- def search(query, limit: 25, pages: 5)
57
- Search.new(config: config).call(query, limit: limit, pages: pages)
56
+ def search(query, limit: 25, pages: 5, type: :all)
57
+ type = MediaType.parse(type)
58
+ pages = [pages, 15].max unless type == :all
59
+ Search.new(config: config).call(query, limit: limit, pages: pages, type: type)
58
60
  end
59
61
 
60
62
  def search_json(query, limit: 25)
@@ -62,21 +64,39 @@ module PinterestDL
62
64
  end
63
65
 
64
66
  def search_images(query, limit: 25)
65
- search(query, limit: limit).filter_map { |p| p[:image_url] }
67
+ search(query, limit: limit, type: :image).filter_map { |p| p[:image_url] }
66
68
  end
67
69
 
68
70
  def search_videos(query, limit: 25)
69
- search(query, limit: limit).filter_map { |p| p[:video_url] }
71
+ search(query, limit: limit, type: :video).filter_map { |p| p[:video_url] }
70
72
  end
71
73
 
72
- def board(board_url, limit: 50, pages: 10)
73
- Board.new(config: config).call(board_url, limit: limit, pages: pages)
74
+ def board(board_url, limit: 50, pages: 10, type: :all)
75
+ type = MediaType.parse(type)
76
+ pages = [pages, 20].max unless type == :all
77
+ Board.new(config: config).call(board_url, limit: limit, pages: pages, type: type)
74
78
  end
75
79
 
76
80
  private
77
81
 
78
- def extract_pin_image(html, quality:)
79
- pin = Extractors::PinData.extract(html)
82
+ def load_pin(pin_url, client)
83
+ validate_pin_url!(pin_url)
84
+ html = client.get(normalize_pin_url(pin_url))
85
+ [html, pin_id_from(client.last_url) || pin_id_from(pin_url)]
86
+ end
87
+
88
+ def normalize_pin_url(pin_url)
89
+ id = pin_id_from(pin_url)
90
+ id ? "https://www.pinterest.com/pin/#{id}/" : pin_url
91
+ end
92
+
93
+ def pin_id_from(url)
94
+ match = url.to_s.match(%r{/pin/(?:[^/?#]*--)?(\d+)})
95
+ match && match[1]
96
+ end
97
+
98
+ def extract_pin_image(html, pin_id, quality:)
99
+ pin = Extractors::PinData.extract(html, pin_id)
80
100
  url = pin && pin[:image_url]
81
101
  url ||= Extractors::Image.extract(html, quality: quality)
82
102
  return nil unless url
@@ -84,8 +104,8 @@ module PinterestDL
84
104
  Extractors::Image.apply_quality(url, quality)
85
105
  end
86
106
 
87
- def extract_pin_video(html)
88
- pin = Extractors::PinData.extract(html)
107
+ def extract_pin_video(html, pin_id)
108
+ pin = Extractors::PinData.extract(html, pin_id)
89
109
  (pin && pin[:video_url]) || Extractors::Video.extract(html)
90
110
  end
91
111
 
@@ -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
+ start_session(username, slug)
18
+ board_id = 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,67 +39,110 @@ module PinterestDL
39
39
 
40
40
  private
41
41
 
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
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
48
+
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
53
+
54
+ parts
55
+ rescue URI::InvalidURIError
56
+ raise PinterestDL::InvalidURLError, "Not a Pinterest board URL: #{board_url}"
57
+ end
58
+
59
+ def start_session(username, slug)
60
+ @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
+ end
65
+
66
+ def find_board_id(username, slug)
67
+ json = call_resource('BoardResource', username: username, slug: slug, field_set_key: 'detailed')
68
+ data = json.dig('resource_response', 'data')
69
+ id = data.is_a?(Hash) && data['id']
70
+ raise PinterestDL::NotFoundError, "Board not found: #{username}/#{slug} (private boards need --cookies)" unless id
71
+
72
+ id
73
+ end
74
+
75
+ def feed_request(board_id, section)
76
+ unless section
77
+ options = {
78
+ board_id: board_id,
79
+ field_set_key: 'react_grid_pin',
80
+ filter_section_pins: true,
81
+ sort: 'default',
82
+ layout: 'default',
83
+ page_size: PAGE_SIZE,
84
+ redux_normalize_feed: true
85
+ }
86
+ return ['BoardFeedResource', options]
87
+ end
88
+
89
+ ['BoardSectionPinsResource', { section_id: find_section_id(board_id, section), page_size: PAGE_SIZE }]
90
+ end
46
91
 
47
- data_node = json.dig('resource_response', 'data') || []
48
- next_bookmarks = json.dig('resource_response', 'bookmark')
92
+ def find_section_id(board_id, slug)
93
+ json = call_resource('BoardSectionsResource', board_id: board_id)
94
+ data = json.dig('resource_response', 'data')
95
+ found = data.is_a?(Array) && data.find { |s| s['slug'] == slug }
96
+ raise PinterestDL::NotFoundError, "Section not found: #{slug}" unless found
49
97
 
50
- { pins: Array(data_node).filter_map { |r| build_pin(r) }, bookmarks: Array(next_bookmarks) }
98
+ found['id']
51
99
  end
52
100
 
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?
101
+ def fetch_page(resource, options, bookmarks)
102
+ options = options.merge(bookmarks: bookmarks) if bookmarks && !bookmarks.empty?
103
+ json = call_resource(resource, options)
60
104
 
105
+ data = json.dig('resource_response', 'data')
106
+ pins = data.is_a?(Array) ? data.filter_map { |r| build_pin(r) } : []
107
+ { pins: pins, bookmarks: Array(json.dig('resource_response', 'bookmark')) }
108
+ end
109
+
110
+ def call_resource(name, options)
61
111
  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
112
+ uri = URI("https://#{@host}/resource/#{name}/get/")
113
+ uri.query = URI.encode_www_form('source_url' => @source_path, 'data' => data)
114
+
115
+ body = @client.get(uri.to_s, use_cache: false, headers: api_headers)
116
+ JSON.parse(body)
117
+ rescue JSON::ParserError, TypeError
118
+ raise PinterestDL::NotFoundError, "Unexpected response from Pinterest (#{name})"
65
119
  end
66
120
 
67
- def request_headers
121
+ def api_headers
68
122
  {
69
123
  'Accept' => 'application/json, text/javascript, */*, q=0.01',
70
- 'X-Requested-With' => 'XMLHttpRequest'
124
+ 'X-Requested-With' => 'XMLHttpRequest',
125
+ 'X-Pinterest-PWS-Handler' => 'www/[username]/[slug].js',
126
+ 'X-CSRFToken' => @client.cookie('csrftoken', @page_url) || 'undefined',
127
+ 'Referer' => @page_url
71
128
  }
72
129
  end
73
130
 
74
131
  def build_pin(raw)
132
+ return nil unless raw.is_a?(Hash)
133
+
75
134
  id = raw['id']
76
135
  return nil unless id
77
136
 
78
- orig_image = raw.dig('images', 'orig', 'url')
79
- video_url = first_video_url(raw)
80
-
81
137
  {
82
138
  id: id,
83
139
  pin_url: "https://www.pinterest.com/pin/#{id}/",
84
140
  title: raw['title'] || raw['grid_title'],
85
141
  description: raw['description'],
86
142
  creator: raw.dig('pinner', 'username') || raw.dig('pinner', 'full_name'),
87
- image_url: orig_image,
88
- video_url: video_url
143
+ image_url: raw.dig('images', 'orig', 'url'),
144
+ video_url: PinterestDL::Extractors::PinData.first_video_url(raw)
89
145
  }
90
146
  end
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
-
99
- def safe_parse(body)
100
- JSON.parse(body)
101
- rescue JSON::ParserError, TypeError
102
- nil
103
- end
104
147
  end
105
148
  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,19 +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
-
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)
82
98
  when Net::HTTPForbidden
83
99
  raise PinterestDL::RateLimitError, "Request to #{url} was blocked (HTTP 403)"
84
100
  else
@@ -86,10 +102,6 @@ module PinterestDL
86
102
  end
87
103
  end
88
104
 
89
- def get_or_raw(url, raw:)
90
- raw ? raw_get(url) : get(url)
91
- end
92
-
93
105
  def build_http(uri)
94
106
  http = Net::HTTP.new(uri.host, uri.port)
95
107
  http.use_ssl = (uri.scheme == 'https')
@@ -98,12 +110,37 @@ module PinterestDL
98
110
  http
99
111
  end
100
112
 
101
- def default_headers
102
- headers = { 'User-Agent' => @config.user_agent }
103
- 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?
104
120
  headers
105
121
  end
106
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
+
107
144
  def throttle!
108
145
  return if @config.rate_limit_interval <= 0
109
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)
@@ -6,13 +6,16 @@ module PinterestDL
6
6
  module Extractors
7
7
  module PinData
8
8
  SCRIPT_PATTERN = %r{<script[^>]*id=["']__PWS_DATA__["'][^>]*>(.*?)</script>}m.freeze
9
+
9
10
  module_function
10
- def extract(html)
11
+
12
+ def extract(html, pin_id = nil)
11
13
  data = parse_embedded_json(html)
12
14
  return nil unless data
13
15
 
14
- pin = find_pin_node(data)
16
+ pin = find_pin_node(data, pin_id)
15
17
  return nil unless pin
18
+
16
19
  {
17
20
  image_url: pin.dig('images', 'orig', 'url'),
18
21
  video_url: first_video_url(pin),
@@ -29,20 +32,24 @@ module PinterestDL
29
32
  nil
30
33
  end
31
34
 
32
- def find_pin_node(data)
35
+ def find_pin_node(data, pin_id = nil)
36
+ matches = []
33
37
  stack = [data]
34
38
  until stack.empty?
35
39
  node = stack.pop
36
40
  case node
37
41
  when Hash
38
- return node if pin_like?(node)
39
-
42
+ if pin_id
43
+ matches << node if node['id'].to_s == pin_id.to_s && has_media?(node)
44
+ elsif pin_like?(node)
45
+ return node
46
+ end
40
47
  stack.concat(node.values)
41
48
  when Array
42
49
  stack.concat(node)
43
50
  end
44
51
  end
45
- nil
52
+ matches.find { |n| first_video_url(n) } || matches.first
46
53
  end
47
54
 
48
55
  def pin_like?(node)
@@ -50,11 +57,42 @@ module PinterestDL
50
57
  images.is_a?(Hash) && images.dig('orig', 'url')
51
58
  end
52
59
 
60
+ def has_media?(node)
61
+ node['images'].is_a?(Hash) || node['videos'].is_a?(Hash) || node['story_pin_data'].is_a?(Hash)
62
+ end
63
+
53
64
  def first_video_url(pin)
54
65
  video_list = pin.dig('videos', 'video_list')
66
+ video_list = deep_find(pin['story_pin_data'], 'video_list') unless video_list.is_a?(Hash)
67
+ best_video(video_list)
68
+ end
69
+
70
+ def best_video(video_list)
55
71
  return nil unless video_list.is_a?(Hash)
56
72
 
57
- video_list.values.first&.dig('url')
73
+ formats = video_list.values.select { |v| v.is_a?(Hash) && v['url'] }
74
+ mp4s = formats.select { |v| v['url'].include?('.mp4') }
75
+ best = mp4s.max_by { |v| v['width'].to_i * v['height'].to_i }
76
+ return best['url'] if best
77
+
78
+ hls = formats.find { |v| v['url'].include?('.m3u8') }
79
+ hls && hls['url'].sub('/hls/', '/720p/').sub(/\.m3u8.*\z/, '.mp4')
80
+ end
81
+
82
+ def deep_find(node, key)
83
+ stack = [node]
84
+ until stack.empty?
85
+ current = stack.pop
86
+ case current
87
+ when Hash
88
+ return current[key] if current[key].is_a?(Hash)
89
+
90
+ stack.concat(current.values)
91
+ when Array
92
+ stack.concat(current)
93
+ end
94
+ end
95
+ nil
58
96
  end
59
97
 
60
98
  def safe_parse(raw)
@@ -3,12 +3,11 @@
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
 
10
9
  def extract(html)
11
- 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)
12
11
  return nil unless url
13
12
 
14
13
  unescape(url)
@@ -19,8 +18,7 @@ module PinterestDL
19
18
  end
20
19
 
21
20
  def from_open_graph(html)
22
- match = html.match(/<meta[^>]*property=["']og:video["'][^>]*content=["']([^"']+)["']/)
23
- match && match[1]
21
+ PinterestDL::Extractors::Meta.content(html, 'og:video')
24
22
  end
25
23
 
26
24
  def from_content_url(html)
@@ -28,11 +26,31 @@ module PinterestDL
28
26
  match && match[1]
29
27
  end
30
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
+
31
35
  def unescape(url)
32
36
  url.gsub('\\u002F', '/').gsub('\\', '')
33
37
  end
34
38
  end
35
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
+
36
54
  module JsonLd
37
55
  module_function
38
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
 
@@ -50,7 +50,8 @@ module PinterestDL
50
50
  parse_search_response(json)
51
51
  end
52
52
 
53
-
53
+ # Pinterest's search resource requires a session cookie + CSRF token
54
+ # obtained by first loading the human-facing search page.
54
55
  def establish_session(query)
55
56
  source_path = "/search/pins/?q=#{URI.encode_www_form_component(query)}"
56
57
  home_uri = URI("https://www.pinterest.com#{source_path}")
@@ -128,10 +129,7 @@ module PinterestDL
128
129
  end
129
130
 
130
131
  def first_video_url(raw)
131
- video_list = raw.dig('videos', 'video_list')
132
- return nil unless video_list
133
-
134
- video_list.values.first&.dig('url')
132
+ PinterestDL::Extractors::PinData.first_video_url(raw)
135
133
  end
136
134
 
137
135
  def safe_parse(body)
@@ -2,5 +2,5 @@
2
2
 
3
3
  # Monji024
4
4
  module PinterestDL
5
- VERSION = '2.0.3'
5
+ VERSION = '2.0.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.3
4
+ version: 2.0.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-29 00:00:00.000000000 Z
11
+ date: 2026-10-12 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