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