pinterest-dl 2.0.4 → 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: ab49a0f8fc2500835b07ec8abf935c4a643d7a9fa8f6e2de9bdb5250f10ef24c
4
- data.tar.gz: 9fb506ca599f3a1625989ab178c7ed4f0c0e5ddad236f416f514666c90741bf0
3
+ metadata.gz: d14e37c46d9bf7d3b8851c433a430da6811a040151e1103912b300985569b038
4
+ data.tar.gz: f19617c2661b7cb970206eabe8a6fb1471dfa9045f09ebf2e0393bfebdc83e5c
5
5
  SHA512:
6
- metadata.gz: e3962e2ea256111c1c9178947a0aa026daec6fed0c1cfefb2f81d92dd77299eebc9c2634f2ecf9e74ede3edfacb327ce28381c9aeffa951f9960c81f8883dddd
7
- data.tar.gz: '09407f8541a4042be42584393524b8aee0147654d46c0abdc1d0c17ad9467bbaa17406fd260099fe214db43aa946553f20a696524616e66e2817477d6cd897b8'
6
+ metadata.gz: 8ac5b7a9d1525a24dee57a7857cc345d8febb84061cb82b018d507c35c03d04e2d214cc108584dcfb56208d6f05f524a74fa427faa9ffd1b25e34314ab597eae
7
+ data.tar.gz: 565a098363fbf28418ef5312972bf2e4dec08d8ade89cad70a59c425bc1a25f052c56eb4c335e48b15237097db9487c833384d4ede470acd685cec986ed98e82
data/CHANGELOG.md CHANGED
@@ -3,6 +3,31 @@
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.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
+
6
31
  ## [2.0.3]
7
32
 
8
33
  ### Fixed
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,20 +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
- def extract_pin_image(html, quality:)
78
- pin = Extractors::PinData.extract(html)
81
+
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)
79
100
  url = pin && pin[:image_url]
80
101
  url ||= Extractors::Image.extract(html, quality: quality)
81
102
  return nil unless url
@@ -83,8 +104,8 @@ module PinterestDL
83
104
  Extractors::Image.apply_quality(url, quality)
84
105
  end
85
106
 
86
- def extract_pin_video(html)
87
- pin = Extractors::PinData.extract(html)
107
+ def extract_pin_video(html, pin_id)
108
+ pin = Extractors::PinData.extract(html, pin_id)
88
109
  (pin && pin[:video_url]) || Extractors::Video.extract(html)
89
110
  end
90
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,93 +39,110 @@ 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
+ @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
57
64
  end
58
65
 
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 }
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
64
71
 
65
- { board_path: board_path, board_uri: board_uri, cookies: cookies, csrf: csrf_token(cookies) }
72
+ id
66
73
  end
67
74
 
68
- def csrf_token(cookies)
69
- token_cookie = cookies.find { |c| c.start_with?('csrftoken=') }
70
- token_cookie ? token_cookie.split('=', 2).last : 'undefined'
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 }]
71
90
  end
72
91
 
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?
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
80
97
 
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
98
+ found['id']
85
99
  end
86
100
 
87
- def board_headers(session)
88
- cookie_header = [@config.cookies, session[:cookies].join('; ')].compact.reject(&:empty?).join('; ')
101
+ def fetch_page(resource, options, bookmarks)
102
+ options = options.merge(bookmarks: bookmarks) if bookmarks && !bookmarks.empty?
103
+ json = call_resource(resource, options)
89
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)
111
+ data = { options: options, context: {} }.to_json
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})"
119
+ end
120
+
121
+ def api_headers
90
122
  {
91
123
  'Accept' => 'application/json, text/javascript, */*, q=0.01',
92
124
  'X-Requested-With' => 'XMLHttpRequest',
93
125
  'X-Pinterest-PWS-Handler' => 'www/[username]/[slug].js',
94
- 'X-CSRFToken' => session[:csrf],
95
- 'Referer' => session[:board_uri].to_s,
96
- 'Cookie' => cookie_header
126
+ 'X-CSRFToken' => @client.cookie('csrftoken', @page_url) || 'undefined',
127
+ 'Referer' => @page_url
97
128
  }
98
129
  end
99
130
 
100
131
  def build_pin(raw)
132
+ return nil unless raw.is_a?(Hash)
133
+
101
134
  id = raw['id']
102
135
  return nil unless id
103
136
 
104
- orig_image = raw.dig('images', 'orig', 'url')
105
- video_url = first_video_url(raw)
106
-
107
137
  {
108
138
  id: id,
109
139
  pin_url: "https://www.pinterest.com/pin/#{id}/",
110
140
  title: raw['title'] || raw['grid_title'],
111
141
  description: raw['description'],
112
142
  creator: raw.dig('pinner', 'username') || raw.dig('pinner', 'full_name'),
113
- image_url: orig_image,
114
- video_url: video_url
143
+ image_url: raw.dig('images', 'orig', 'url'),
144
+ video_url: PinterestDL::Extractors::PinData.first_video_url(raw)
115
145
  }
116
146
  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
147
  end
131
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,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)
@@ -9,11 +9,11 @@ module PinterestDL
9
9
 
10
10
  module_function
11
11
 
12
- def extract(html)
12
+ def extract(html, pin_id = nil)
13
13
  data = parse_embedded_json(html)
14
14
  return nil unless data
15
15
 
16
- pin = find_pin_node(data)
16
+ pin = find_pin_node(data, pin_id)
17
17
  return nil unless pin
18
18
 
19
19
  {
@@ -32,20 +32,24 @@ module PinterestDL
32
32
  nil
33
33
  end
34
34
 
35
- def find_pin_node(data)
35
+ def find_pin_node(data, pin_id = nil)
36
+ matches = []
36
37
  stack = [data]
37
38
  until stack.empty?
38
39
  node = stack.pop
39
40
  case node
40
41
  when Hash
41
- return node if pin_like?(node)
42
-
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
43
47
  stack.concat(node.values)
44
48
  when Array
45
49
  stack.concat(node)
46
50
  end
47
51
  end
48
- nil
52
+ matches.find { |n| first_video_url(n) } || matches.first
49
53
  end
50
54
 
51
55
  def pin_like?(node)
@@ -53,11 +57,42 @@ module PinterestDL
53
57
  images.is_a?(Hash) && images.dig('orig', 'url')
54
58
  end
55
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
+
56
64
  def first_video_url(pin)
57
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)
58
71
  return nil unless video_list.is_a?(Hash)
59
72
 
60
- 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
61
96
  end
62
97
 
63
98
  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
 
@@ -129,10 +129,7 @@ module PinterestDL
129
129
  end
130
130
 
131
131
  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')
132
+ PinterestDL::Extractors::PinData.first_video_url(raw)
136
133
  end
137
134
 
138
135
  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.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.4
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-30 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