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 +4 -4
- data/CHANGELOG.md +31 -1
- data/README.md +4 -1
- data/bin/pinterest-dl +33 -22
- data/lib/pinterest-dl.rb +1 -0
- data/lib/pinterest_dl/api.rb +41 -21
- data/lib/pinterest_dl/board.rb +89 -46
- data/lib/pinterest_dl/client.rb +67 -30
- data/lib/pinterest_dl/extractors/image.rb +1 -2
- data/lib/pinterest_dl/extractors/pin_data.rb +45 -7
- data/lib/pinterest_dl/extractors/video.rb +22 -4
- data/lib/pinterest_dl/media_type.rb +25 -0
- data/lib/pinterest_dl/search.rb +5 -7
- data/lib/pinterest_dl/version.rb +1 -1
- metadata +4 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: d14e37c46d9bf7d3b8851c433a430da6811a040151e1103912b300985569b038
|
|
4
|
+
data.tar.gz: f19617c2661b7cb970206eabe8a6fb1471dfa9045f09ebf2e0393bfebdc83e5c
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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'
|
data/lib/pinterest_dl/api.rb
CHANGED
|
@@ -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
|
-
|
|
23
|
-
|
|
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
|
-
|
|
32
|
-
|
|
33
|
-
url
|
|
34
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
79
|
-
|
|
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
|
|
data/lib/pinterest_dl/board.rb
CHANGED
|
@@ -5,31 +5,31 @@ require 'uri'
|
|
|
5
5
|
|
|
6
6
|
module PinterestDL
|
|
7
7
|
class Board
|
|
8
|
-
|
|
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
|
-
|
|
19
|
-
|
|
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(
|
|
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.
|
|
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
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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
|
-
|
|
48
|
-
|
|
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
|
-
|
|
98
|
+
found['id']
|
|
51
99
|
end
|
|
52
100
|
|
|
53
|
-
def
|
|
54
|
-
options =
|
|
55
|
-
|
|
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
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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
|
|
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:
|
|
88
|
-
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
|
data/lib/pinterest_dl/client.rb
CHANGED
|
@@ -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
|
-
|
|
24
|
+
if use_cache && @cache.key?(cache_key)
|
|
25
|
+
body, @last_url = @cache[cache_key]
|
|
26
|
+
return body
|
|
27
|
+
end
|
|
19
28
|
|
|
20
|
-
|
|
21
|
-
|
|
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
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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
|
|
87
|
+
def check_response(response, url)
|
|
59
88
|
case response
|
|
60
89
|
when Net::HTTPSuccess
|
|
61
|
-
|
|
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 = {
|
|
103
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
data/lib/pinterest_dl/search.rb
CHANGED
|
@@ -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
|
-
|
|
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)
|
data/lib/pinterest_dl/version.rb
CHANGED
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
|
+
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-
|
|
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.
|
|
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
|