pinterest-dl 2.0.0 → 2.0.3
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 +22 -0
- data/lib/pinterest-dl.rb +2 -0
- data/lib/pinterest_dl/api.rb +21 -7
- data/lib/pinterest_dl/board.rb +45 -46
- data/lib/pinterest_dl/client.rb +8 -1
- data/lib/pinterest_dl/extractors/image.rb +3 -1
- data/lib/pinterest_dl/extractors/pin_data.rb +67 -0
- data/lib/pinterest_dl/extractors/video.rb +0 -2
- data/lib/pinterest_dl/search.rb +4 -3
- data/lib/pinterest_dl/version.rb +1 -1
- metadata +3 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 2ba41b3cdd44c6463cfffde77fc4b9db8185f47b1d62e3a9555b9408bfd4f197
|
|
4
|
+
data.tar.gz: b032e121ffb9750ed128b0a0c6ce5d0266dea8db4fbc99ee1bd6579e8f680d1a
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 11fe875c2a1eabda28fb7f7b4f342b33565dc141b5fd7ff06cac800595a834c9156abd9fe61544f75eed944f0553fc180183cad5ac7fdef6ca13a03023e179ae
|
|
7
|
+
data.tar.gz: dc3600f15608c7717f2010cceef198e636e423b3788ee5763ff4e49fdfd19f49cef545e5206d1a7a0981d658194a97745aecbc0f373da038062f381b2ad74ec6
|
data/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,28 @@
|
|
|
3
3
|
All notable changes to this project are documented here.
|
|
4
4
|
This project follows [Semantic Versioning](https://semver.org/).
|
|
5
5
|
|
|
6
|
+
## [2.0.2]
|
|
7
|
+
|
|
8
|
+
### Fixed
|
|
9
|
+
- **`PinterestDL::Board` was broken.** `lib/pinterest_dl/board.rb` accidentally
|
|
10
|
+
defined `class Search` instead of `class Board` (with a full copy of the old
|
|
11
|
+
search logic), so it silently collided with `PinterestDL::Search` at load
|
|
12
|
+
time and `PinterestDL.board(...)` never worked correctly. Restored the
|
|
13
|
+
correct `Board` class backed by Pinterest's `BoardFeedResource`.
|
|
14
|
+
- `pin.it/...` short links were rejected by `get_image_url` / `get_video_url` /
|
|
15
|
+
`get_media` even though Pinterest itself redirects them to a normal pin
|
|
16
|
+
page. `PIN_URL_PATTERN` now accepts `pin.it/...` links too.
|
|
17
|
+
- The raw-HTML fallback regex used to find an image when no `og:image` or
|
|
18
|
+
JSON-LD tag was present could "bleed" past the actual image URL into
|
|
19
|
+
trailing inline CSS (e.g. `...img.png)}._YsBbF{border:0...`), producing a
|
|
20
|
+
garbage, unusable URL. The regex now requires a real image extension and
|
|
21
|
+
only matches valid URL characters, so it stops exactly at the image URL.
|
|
22
|
+
- Image/video extraction now tries Pinterest's embedded page-state JSON
|
|
23
|
+
(`<script id="__PWS_DATA__">`) first via a new `Extractors::PinData`
|
|
24
|
+
module, which is far more reliable than scraping meta tags/regex and is
|
|
25
|
+
immune to the CSS-bleed issue above; the old scraping strategies remain as
|
|
26
|
+
a fallback if a page doesn't embed that data.
|
|
27
|
+
|
|
6
28
|
## [2.0.0]
|
|
7
29
|
|
|
8
30
|
### Added
|
data/lib/pinterest-dl.rb
CHANGED
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
# Monji024
|
|
4
|
+
require 'json'
|
|
4
5
|
require_relative 'pinterest_dl/version'
|
|
5
6
|
require_relative 'pinterest_dl/errors'
|
|
6
7
|
require_relative 'pinterest_dl/configuration'
|
|
7
8
|
require_relative 'pinterest_dl/client'
|
|
8
9
|
require_relative 'pinterest_dl/extractors/image'
|
|
9
10
|
require_relative 'pinterest_dl/extractors/video'
|
|
11
|
+
require_relative 'pinterest_dl/extractors/pin_data'
|
|
10
12
|
require_relative 'pinterest_dl/search'
|
|
11
13
|
require_relative 'pinterest_dl/board'
|
|
12
14
|
require_relative 'pinterest_dl/downloader'
|
data/lib/pinterest_dl/api.rb
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
# Monji024
|
|
4
4
|
module PinterestDL
|
|
5
|
-
PIN_URL_PATTERN = %r{pinterest\.[a-z.]+/pin/}i.freeze
|
|
5
|
+
PIN_URL_PATTERN = %r{(pinterest\.[a-z.]+/pin/|pin\.it/)}i.freeze
|
|
6
6
|
|
|
7
7
|
class << self
|
|
8
8
|
def config
|
|
@@ -21,7 +21,7 @@ module PinterestDL
|
|
|
21
21
|
def get_image_url(pin_url, quality: config.quality, client: Client.new(config: config))
|
|
22
22
|
validate_pin_url!(pin_url)
|
|
23
23
|
html = client.get(pin_url)
|
|
24
|
-
url =
|
|
24
|
+
url = extract_pin_image(html, quality: quality)
|
|
25
25
|
raise NotFoundError, "No image found at #{pin_url}" unless url
|
|
26
26
|
|
|
27
27
|
url
|
|
@@ -30,18 +30,18 @@ module PinterestDL
|
|
|
30
30
|
def get_video_url(pin_url, client: Client.new(config: config))
|
|
31
31
|
validate_pin_url!(pin_url)
|
|
32
32
|
html = client.get(pin_url)
|
|
33
|
-
url =
|
|
33
|
+
url = extract_pin_video(html)
|
|
34
34
|
raise NotFoundError, "No video found at #{pin_url}" unless url
|
|
35
35
|
|
|
36
36
|
url
|
|
37
37
|
end
|
|
38
38
|
|
|
39
|
-
def get_media(pin_url, quality: config.quality)
|
|
40
|
-
|
|
39
|
+
def get_media(pin_url, quality: config.quality, client: Client.new(config: config))
|
|
40
|
+
validate_pin_url!(pin_url)
|
|
41
41
|
html = client.get(pin_url)
|
|
42
42
|
{
|
|
43
|
-
image_url:
|
|
44
|
-
video_url:
|
|
43
|
+
image_url: extract_pin_image(html, quality: quality),
|
|
44
|
+
video_url: extract_pin_video(html)
|
|
45
45
|
}
|
|
46
46
|
end
|
|
47
47
|
|
|
@@ -75,6 +75,20 @@ module PinterestDL
|
|
|
75
75
|
|
|
76
76
|
private
|
|
77
77
|
|
|
78
|
+
def extract_pin_image(html, quality:)
|
|
79
|
+
pin = Extractors::PinData.extract(html)
|
|
80
|
+
url = pin && pin[:image_url]
|
|
81
|
+
url ||= Extractors::Image.extract(html, quality: quality)
|
|
82
|
+
return nil unless url
|
|
83
|
+
|
|
84
|
+
Extractors::Image.apply_quality(url, quality)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def extract_pin_video(html)
|
|
88
|
+
pin = Extractors::PinData.extract(html)
|
|
89
|
+
(pin && pin[:video_url]) || Extractors::Video.extract(html)
|
|
90
|
+
end
|
|
91
|
+
|
|
78
92
|
def validate_pin_url!(pin_url)
|
|
79
93
|
return if pin_url.to_s.match?(PIN_URL_PATTERN)
|
|
80
94
|
|
data/lib/pinterest_dl/board.rb
CHANGED
|
@@ -1,40 +1,37 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require 'json'
|
|
4
|
-
require 'net/http'
|
|
5
4
|
require 'uri'
|
|
6
5
|
|
|
7
6
|
module PinterestDL
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
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}
|
|
12
11
|
|
|
13
12
|
def initialize(client: PinterestDL::Client.new, config: PinterestDL.config)
|
|
14
13
|
@client = client
|
|
15
14
|
@config = config
|
|
16
15
|
end
|
|
17
16
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
def call(query, limit: 25, pages: 5)
|
|
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
|
|
20
|
+
|
|
23
21
|
results = []
|
|
24
22
|
bookmarks = nil
|
|
25
23
|
pages_fetched = 0
|
|
26
24
|
|
|
27
25
|
loop do
|
|
28
|
-
page = fetch_page(
|
|
29
|
-
|
|
30
|
-
results.concat(pins)
|
|
26
|
+
page = fetch_page(match, bookmarks: bookmarks)
|
|
27
|
+
results.concat(page[:pins])
|
|
31
28
|
bookmarks = page[:bookmarks]
|
|
32
29
|
pages_fetched += 1
|
|
33
30
|
|
|
34
31
|
break if results.size >= limit
|
|
35
32
|
break if bookmarks.nil? || bookmarks.empty? || bookmarks == ['-end-']
|
|
36
33
|
break if pages_fetched >= pages
|
|
37
|
-
break if pins.empty?
|
|
34
|
+
break if page[:pins].empty?
|
|
38
35
|
end
|
|
39
36
|
|
|
40
37
|
results.first(limit)
|
|
@@ -42,40 +39,36 @@ module PinterestDL
|
|
|
42
39
|
|
|
43
40
|
private
|
|
44
41
|
|
|
45
|
-
def fetch_page(
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
home_response = @client.raw_get(home_uri.to_s)
|
|
50
|
-
session_cookies = (home_response.get_fields('set-cookie') || []).map { |c| c.split(';').first }
|
|
51
|
-
cookie_header = [@config.cookies, session_cookies.join('; ')].compact.reject(&:empty?).join('; ')
|
|
52
|
-
csrf = session_cookies.find { |c| c.start_with?('csrftoken=') }&.split('=', 2)&.last || 'undefined'
|
|
53
|
-
|
|
54
|
-
options = { query: query, scope: 'pins', page_size: 25 }
|
|
55
|
-
options[:bookmarks] = bookmarks if bookmarks && !bookmarks.empty?
|
|
56
|
-
data = { options: options, context: {} }.to_json
|
|
42
|
+
def fetch_page(match, bookmarks:)
|
|
43
|
+
body = @client.get(build_request_url(match, bookmarks), use_cache: false, headers: request_headers)
|
|
44
|
+
json = safe_parse(body)
|
|
45
|
+
raise PinterestDL::NotFoundError, "Board not found: #{match[0]}" unless json
|
|
57
46
|
|
|
58
|
-
|
|
59
|
-
|
|
47
|
+
data_node = json.dig('resource_response', 'data') || []
|
|
48
|
+
next_bookmarks = json.dig('resource_response', 'bookmark')
|
|
60
49
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
'X-Requested-With' => 'XMLHttpRequest',
|
|
64
|
-
'X-Pinterest-PWS-Handler' => 'www/search/[scope].js',
|
|
65
|
-
'X-CSRFToken' => csrf,
|
|
66
|
-
'Referer' => home_uri.to_s,
|
|
67
|
-
'Cookie' => cookie_header
|
|
68
|
-
})
|
|
50
|
+
{ pins: Array(data_node).filter_map { |r| build_pin(r) }, bookmarks: Array(next_bookmarks) }
|
|
51
|
+
end
|
|
69
52
|
|
|
70
|
-
|
|
71
|
-
|
|
53
|
+
def build_request_url(match, bookmarks)
|
|
54
|
+
options = {
|
|
55
|
+
board_url: "/#{match[:username]}/#{match[:slug]}/",
|
|
56
|
+
board_id: '',
|
|
57
|
+
section_slug: match[:section]
|
|
58
|
+
}.compact
|
|
59
|
+
options[:bookmarks] = bookmarks if bookmarks && !bookmarks.empty?
|
|
72
60
|
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
61
|
+
data = { options: options, context: {} }.to_json
|
|
62
|
+
res_uri = URI(B_URL)
|
|
63
|
+
res_uri.query = URI.encode_www_form('source_url' => options[:board_url], 'data' => data)
|
|
64
|
+
res_uri.to_s
|
|
65
|
+
end
|
|
77
66
|
|
|
78
|
-
|
|
67
|
+
def request_headers
|
|
68
|
+
{
|
|
69
|
+
'Accept' => 'application/json, text/javascript, */*, q=0.01',
|
|
70
|
+
'X-Requested-With' => 'XMLHttpRequest'
|
|
71
|
+
}
|
|
79
72
|
end
|
|
80
73
|
|
|
81
74
|
def build_pin(raw)
|
|
@@ -83,8 +76,7 @@ module PinterestDL
|
|
|
83
76
|
return nil unless id
|
|
84
77
|
|
|
85
78
|
orig_image = raw.dig('images', 'orig', 'url')
|
|
86
|
-
|
|
87
|
-
video_url = raw.dig('videos', 'video_list')&.values&.first&.dig('url')
|
|
79
|
+
video_url = first_video_url(raw)
|
|
88
80
|
|
|
89
81
|
{
|
|
90
82
|
id: id,
|
|
@@ -92,11 +84,18 @@ module PinterestDL
|
|
|
92
84
|
title: raw['title'] || raw['grid_title'],
|
|
93
85
|
description: raw['description'],
|
|
94
86
|
creator: raw.dig('pinner', 'username') || raw.dig('pinner', 'full_name'),
|
|
95
|
-
image_url: orig_image
|
|
87
|
+
image_url: orig_image,
|
|
96
88
|
video_url: video_url
|
|
97
89
|
}
|
|
98
90
|
end
|
|
99
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
|
+
|
|
100
99
|
def safe_parse(body)
|
|
101
100
|
JSON.parse(body)
|
|
102
101
|
rescue JSON::ParserError, TypeError
|
data/lib/pinterest_dl/client.rb
CHANGED
|
@@ -71,7 +71,14 @@ module PinterestDL
|
|
|
71
71
|
raise PinterestDL::NotFoundError, "Redirected without a location header (#{url})" unless location
|
|
72
72
|
|
|
73
73
|
location = URI.join(url, location).to_s if location.start_with?('/')
|
|
74
|
-
|
|
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)
|
|
75
82
|
when Net::HTTPForbidden
|
|
76
83
|
raise PinterestDL::RateLimitError, "Request to #{url} was blocked (HTTP 403)"
|
|
77
84
|
else
|
|
@@ -30,7 +30,9 @@ module PinterestDL
|
|
|
30
30
|
end
|
|
31
31
|
|
|
32
32
|
def from_raw_pattern(html)
|
|
33
|
-
match = html.match(
|
|
33
|
+
match = html.match(
|
|
34
|
+
%r{https://i\.pinimg\.com/(?:originals|736x|474x|236x)/[A-Za-z0-9_\-/]+\.(?:jpg|jpeg|png|gif|webp)}i
|
|
35
|
+
)
|
|
34
36
|
match && match[0]
|
|
35
37
|
end
|
|
36
38
|
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
|
|
5
|
+
module PinterestDL
|
|
6
|
+
module Extractors
|
|
7
|
+
module PinData
|
|
8
|
+
SCRIPT_PATTERN = %r{<script[^>]*id=["']__PWS_DATA__["'][^>]*>(.*?)</script>}m.freeze
|
|
9
|
+
module_function
|
|
10
|
+
def extract(html)
|
|
11
|
+
data = parse_embedded_json(html)
|
|
12
|
+
return nil unless data
|
|
13
|
+
|
|
14
|
+
pin = find_pin_node(data)
|
|
15
|
+
return nil unless pin
|
|
16
|
+
{
|
|
17
|
+
image_url: pin.dig('images', 'orig', 'url'),
|
|
18
|
+
video_url: first_video_url(pin),
|
|
19
|
+
title: pin['title'] || pin['grid_title'],
|
|
20
|
+
description: pin['description']
|
|
21
|
+
}
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def parse_embedded_json(html)
|
|
25
|
+
html.to_s.scan(SCRIPT_PATTERN).each do |(raw)|
|
|
26
|
+
parsed = safe_parse(raw)
|
|
27
|
+
return parsed if parsed
|
|
28
|
+
end
|
|
29
|
+
nil
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def find_pin_node(data)
|
|
33
|
+
stack = [data]
|
|
34
|
+
until stack.empty?
|
|
35
|
+
node = stack.pop
|
|
36
|
+
case node
|
|
37
|
+
when Hash
|
|
38
|
+
return node if pin_like?(node)
|
|
39
|
+
|
|
40
|
+
stack.concat(node.values)
|
|
41
|
+
when Array
|
|
42
|
+
stack.concat(node)
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
nil
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def pin_like?(node)
|
|
49
|
+
images = node['images']
|
|
50
|
+
images.is_a?(Hash) && images.dig('orig', 'url')
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def first_video_url(pin)
|
|
54
|
+
video_list = pin.dig('videos', 'video_list')
|
|
55
|
+
return nil unless video_list.is_a?(Hash)
|
|
56
|
+
|
|
57
|
+
video_list.values.first&.dig('url')
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def safe_parse(raw)
|
|
61
|
+
JSON.parse(raw)
|
|
62
|
+
rescue JSON::ParserError
|
|
63
|
+
nil
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
data/lib/pinterest_dl/search.rb
CHANGED
|
@@ -6,7 +6,7 @@ require 'uri'
|
|
|
6
6
|
|
|
7
7
|
module PinterestDL
|
|
8
8
|
class Search
|
|
9
|
-
|
|
9
|
+
S_URL = 'https://www.pinterest.com/resource/BaseSearchResource/get/'
|
|
10
10
|
|
|
11
11
|
def initialize(client: PinterestDL::Client.new, config: PinterestDL.config)
|
|
12
12
|
@client = client
|
|
@@ -50,6 +50,7 @@ module PinterestDL
|
|
|
50
50
|
parse_search_response(json)
|
|
51
51
|
end
|
|
52
52
|
|
|
53
|
+
|
|
53
54
|
def establish_session(query)
|
|
54
55
|
source_path = "/search/pins/?q=#{URI.encode_www_form_component(query)}"
|
|
55
56
|
home_uri = URI("https://www.pinterest.com#{source_path}")
|
|
@@ -69,7 +70,7 @@ module PinterestDL
|
|
|
69
70
|
options[:bookmarks] = bookmarks if bookmarks && !bookmarks.empty?
|
|
70
71
|
data = { options: options, context: {} }.to_json
|
|
71
72
|
|
|
72
|
-
res_uri = URI(
|
|
73
|
+
res_uri = URI(S_URL)
|
|
73
74
|
res_uri.query = URI.encode_www_form('source_url' => source_path, 'data' => data)
|
|
74
75
|
res_uri.to_s
|
|
75
76
|
end
|
|
@@ -91,7 +92,7 @@ module PinterestDL
|
|
|
91
92
|
data_node = json.dig('resource_response', 'data') || {}
|
|
92
93
|
raw_results = data_node['results'] || []
|
|
93
94
|
next_bookmarks = json.dig('resource_response', 'bookmark') ||
|
|
94
|
-
|
|
95
|
+
json.dig('resource', 'options', 'bookmarks')
|
|
95
96
|
|
|
96
97
|
{ pins: raw_results.filter_map { |r| build_pin(r) }, bookmarks: Array(next_bookmarks) }
|
|
97
98
|
end
|
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.3
|
|
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-
|
|
11
|
+
date: 2026-08-29 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
|
|
@@ -31,6 +31,7 @@ files:
|
|
|
31
31
|
- lib/pinterest_dl/downloader.rb
|
|
32
32
|
- lib/pinterest_dl/errors.rb
|
|
33
33
|
- lib/pinterest_dl/extractors/image.rb
|
|
34
|
+
- lib/pinterest_dl/extractors/pin_data.rb
|
|
34
35
|
- lib/pinterest_dl/extractors/video.rb
|
|
35
36
|
- lib/pinterest_dl/progress_bar.rb
|
|
36
37
|
- lib/pinterest_dl/search.rb
|