pinterest-dl 2.0.5 → 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: d14e37c46d9bf7d3b8851c433a430da6811a040151e1103912b300985569b038
4
- data.tar.gz: f19617c2661b7cb970206eabe8a6fb1471dfa9045f09ebf2e0393bfebdc83e5c
3
+ metadata.gz: 51b7122dcca9ae653e0f63a70665fa8b5dfddd87a9d7e17eeef3462843ed90c1
4
+ data.tar.gz: e82c0d139c5bf84dfd5e13fa96488d8013636f72ce7e2bdc5bc9a8efc49151cc
5
5
  SHA512:
6
- metadata.gz: 8ac5b7a9d1525a24dee57a7857cc345d8febb84061cb82b018d507c35c03d04e2d214cc108584dcfb56208d6f05f524a74fa427faa9ffd1b25e34314ab597eae
7
- data.tar.gz: 565a098363fbf28418ef5312972bf2e4dec08d8ade89cad70a59c425bc1a25f052c56eb4c335e48b15237097db9487c833384d4ede470acd685cec986ed98e82
6
+ metadata.gz: 911490799bd9db4405781306091bbe06271413e25b834190d46394ad7b288be5ce3153ba25d9261122dbb4efe0be08188c49583751999d08e87be223a017300e
7
+ data.tar.gz: f18ffdcc24defdbf6bb392f738ed6b6dc62047ccecacfb90eb90fb52a879b18dd84c7677288d538f8105ecae7e611faec6470a0dfeaedb07f8767d9e822c0e20
data/CHANGELOG.md CHANGED
@@ -3,7 +3,7 @@
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]
6
+ ## [2.1.5]
7
7
 
8
8
  ### Fixed
9
9
  - Pin URLs on country domains (e.g. `de.pinterest.com/pin/...`) didn't download
@@ -17,6 +17,14 @@ This project follows [Semantic Versioning](https://semver.org/).
17
17
  - Video pins returned "No video found". The best `.mp4` is picked now (not just
18
18
  the first format, which could be an `.m3u8`), story pins are supported, and
19
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.
20
28
  - `board` never worked: Pinterest needs the real board id, we sent an empty
21
29
  one. The id is now looked up first, the session is created once instead of
22
30
  once per page, links with `?invite_code=...` are parsed correctly, and
@@ -89,4 +97,4 @@ This project follows [Semantic Versioning](https://semver.org/).
89
97
  ## [1.0.3]
90
98
 
91
99
  - Initial public release: `get_image_url`, `get_video_url`, `search`,
92
- `search_json`, `search_images`, `search_videos`.
100
+ `search_json`, `search_images`, `search_videos`.
data/bin/pinterest-dl CHANGED
@@ -19,7 +19,6 @@ def usage
19
19
  -t, --type TYPE What to collect in search/board: all, image, video, pin (default all)
20
20
  -q, --quality Q Image quality: originals, 736x, 474x, 236x (default originals)
21
21
  -c, --cookies STR Cookie string for authenticated requests
22
- -v, --verbose Print debug logging
23
22
  -h, --help Show this help
24
23
  USAGE
25
24
  end
@@ -31,7 +30,6 @@ parser = OptionParser.new do |opts|
31
30
  opts.on('-t T', '--type T') { |v| options[:type] = v.to_sym }
32
31
  opts.on('-q Q', '--quality Q') { |v| options[:quality] = v.to_sym }
33
32
  opts.on('-c STR', '--cookies STR') { |v| options[:cookies] = v }
34
- opts.on('-v', '--verbose') { options[:verbose] = true }
35
33
  opts.on('-h', '--help') do
36
34
  puts usage
37
35
  exit 0
@@ -21,20 +21,25 @@ module PinterestDL
21
21
  def get_image_url(pin_url, quality: config.quality, client: Client.new(config: config))
22
22
  html, pin_id = load_pin(pin_url, client)
23
23
  url = extract_pin_image(html, pin_id, quality: quality)
24
+ url ||= api_image_url(pin_id, client, quality)
24
25
  raise NotFoundError, "No image found at #{pin_url}" unless url
25
26
 
26
27
  url
27
28
  end
28
29
 
29
30
  def get_video_url(pin_url, client: Client.new(config: config))
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}"
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
36
+
37
+ pin = pin_from_api(pin_id, client)
38
+ return pin[:video_url] if pin && pin[:video_url]
35
39
  end
36
40
 
37
- url
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}"
38
43
  end
39
44
 
40
45
  def get_media(pin_url, quality: config.quality, client: Client.new(config: config))
@@ -79,10 +84,40 @@ module PinterestDL
79
84
 
80
85
  private
81
86
 
82
- def load_pin(pin_url, client)
87
+ def load_pin(pin_url, client, fresh: false)
83
88
  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)]
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)
86
121
  end
87
122
 
88
123
  def normalize_pin_url(pin_url)
@@ -14,8 +14,8 @@ module PinterestDL
14
14
 
15
15
  def call(board_url, limit: 50, pages: 10, type: :all)
16
16
  username, slug, section = parse_url(board_url)
17
- start_session(username, slug)
18
- board_id = find_board_id(username, slug)
17
+ html = start_session(username, slug)
18
+ board_id = board_id_from_page(html, slug) || find_board_id(username, slug)
19
19
  resource, options = feed_request(board_id, section)
20
20
 
21
21
  results = []
@@ -57,10 +57,34 @@ module PinterestDL
57
57
  end
58
58
 
59
59
  def start_session(username, slug)
60
- @client.raw_get("https://www.pinterest.com/#{username}/#{slug}/")
60
+ response = @client.raw_get("https://www.pinterest.com/#{username}/#{slug}/")
61
61
  @page_url = @client.last_url
62
62
  @host = URI(@page_url).host
63
63
  @source_path = URI(@page_url).path
64
+ response.body
65
+ end
66
+
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
85
+
86
+ def board_node?(node, slug)
87
+ node['type'] == 'board' && node['id'].to_s.match?(/\A\d+\z/) && node['url'].to_s.include?("/#{slug}/")
64
88
  end
65
89
 
66
90
  def find_board_id(username, slug)
@@ -130,6 +154,7 @@ module PinterestDL
130
154
 
131
155
  def build_pin(raw)
132
156
  return nil unless raw.is_a?(Hash)
157
+ return nil if raw['type'] && raw['type'] != 'pin'
133
158
 
134
159
  id = raw['id']
135
160
  return nil unless id
@@ -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
12
  def extract(html, pin_id = nil)
13
- data = parse_embedded_json(html)
14
- return nil unless data
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, pin_id)
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),
@@ -76,7 +85,15 @@ module PinterestDL
76
85
  return best['url'] if best
77
86
 
78
87
  hls = formats.find { |v| v['url'].include?('.m3u8') }
79
- hls && hls['url'].sub('/hls/', '/720p/').sub(/\.m3u8.*\z/, '.mp4')
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
80
97
  end
81
98
 
82
99
  def deep_find(node, key)
@@ -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}")
@@ -2,5 +2,5 @@
2
2
 
3
3
  # Monji024
4
4
  module PinterestDL
5
- VERSION = '2.0.5'
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.5
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-10-12 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