scrape_creators 0.5.0 → 0.7.0
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/README.md +52 -11
- data/lib/scrape_creators/client.rb +101 -38
- data/lib/scrape_creators/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 385c61c807a5dd2f2f1d7106b9430991d97ba68d5e894b6675d58d324243e1c3
|
|
4
|
+
data.tar.gz: 6293f02f4f4724cc2e6b1a11447e66c38ac2440db19f2b31b362a1a260757d81
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 16846f8a031d0629aece90c2b59ae7f59026a4de1507f0e17a35d036b7c18efb185cdc619babf04bbbe1b81dc4b792ce186d576e58dc7cbd8bea8e33098e6d59
|
|
7
|
+
data.tar.gz: e7d80c9c3d21e7b1482776421c9f70ab6a33759ff3e8ec20d674e2b638dd146084917cb4966158b9b2451a8077ea72981f0500b767a9c8a333b407a58ea9f5c7
|
data/README.md
CHANGED
|
@@ -7,7 +7,7 @@ A zero-dependency Ruby client for the [Scrape Creators API](https://docs.scrapec
|
|
|
7
7
|
Add this line to your application's Gemfile:
|
|
8
8
|
|
|
9
9
|
```ruby
|
|
10
|
-
gem 'scrape_creators', '0.
|
|
10
|
+
gem 'scrape_creators', '0.6.0'
|
|
11
11
|
```
|
|
12
12
|
|
|
13
13
|
And then execute:
|
|
@@ -40,6 +40,39 @@ scraper = ScrapeCreators::Client.new
|
|
|
40
40
|
posts = scraper.posts("zuck")
|
|
41
41
|
```
|
|
42
42
|
|
|
43
|
+
### Fetch TikTok Profile Videos
|
|
44
|
+
|
|
45
|
+
```ruby
|
|
46
|
+
posts = scraper.posts("zuck", source: 'tiktok')
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
### Fetch YouTube Channel Shorts
|
|
50
|
+
|
|
51
|
+
```ruby
|
|
52
|
+
posts = scraper.posts("zuck", source: 'youtube')
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
### Cursor-based pagination
|
|
56
|
+
|
|
57
|
+
All three platforms support cursor-based pagination. By default `posts` returns the first page only. Pass `pages:` to fetch more (e.g. `5` for the first five pages of a creator's feed):
|
|
58
|
+
|
|
59
|
+
```ruby
|
|
60
|
+
posts = scraper.posts("zuck", pages: 5) # Instagram
|
|
61
|
+
posts = scraper.posts("zuck", source: 'tiktok', pages: 5) # TikTok
|
|
62
|
+
posts = scraper.posts("zuck", source: 'youtube', pages: 5) # YouTube Shorts
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
If you need finer control, `posts_page` returns `{ items:, cursor:, has_more: }` so you can page through the full timeline yourself:
|
|
66
|
+
|
|
67
|
+
```ruby
|
|
68
|
+
page = scraper.posts_page("zuck", source: 'tiktok')
|
|
69
|
+
loop do
|
|
70
|
+
page[:items].each { |item| puts item['desc'] }
|
|
71
|
+
break unless page[:has_more] && page[:cursor]
|
|
72
|
+
page = scraper.posts_page("zuck", source: 'tiktok', cursor: page[:cursor])
|
|
73
|
+
end
|
|
74
|
+
```
|
|
75
|
+
|
|
43
76
|
### Fetch Instagram Post / Reel Info
|
|
44
77
|
|
|
45
78
|
```ruby
|
|
@@ -50,18 +83,26 @@ info = scraper.post("https://www.instagram.com/p/DKSMEpKRd6h/", download_media:
|
|
|
50
83
|
|
|
51
84
|
```ruby
|
|
52
85
|
comments = scraper.comments("https://www.instagram.com/p/DKSMEpKRd6h/")
|
|
53
|
-
|
|
54
|
-
page = scraper.comments_page("https://www.instagram.com/p/DKSMEpKRd6h/")
|
|
55
|
-
page[:comments]
|
|
56
|
-
page[:cursor]
|
|
57
|
-
page[:has_more]
|
|
58
|
-
|
|
59
|
-
next_page = scraper.comments_page(
|
|
60
|
-
"https://www.instagram.com/p/DKSMEpKRd6h/",
|
|
61
|
-
cursor: page[:cursor]
|
|
62
|
-
)
|
|
63
86
|
```
|
|
64
87
|
|
|
65
88
|
## License
|
|
66
89
|
|
|
67
90
|
The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
|
|
91
|
+
|
|
92
|
+
## Account search
|
|
93
|
+
|
|
94
|
+
```ruby
|
|
95
|
+
client.search_users("sean walker", source: "instagram")
|
|
96
|
+
client.search_users("sean walker", source: "tiktok")
|
|
97
|
+
client.search_users("sean walker", source: "youtube")
|
|
98
|
+
# => [{ handle: "seanwalker", name: "Sean Walker", avatar_url: "https://..." }]
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
Returns one page of accounts, deduplicated by handle. YouTube searches channels,
|
|
102
|
+
whose handles can be passed to `posts_page` to fetch Shorts. Results without a
|
|
103
|
+
usable handle are omitted. Blank queries and unsupported sources raise
|
|
104
|
+
`ArgumentError`; unsuccessful API responses raise `ScrapeCreators::APIError`.
|
|
105
|
+
|
|
106
|
+
Endpoints: [Instagram](https://docs.scrapecreators.com/v1/instagram/search/),
|
|
107
|
+
[TikTok](https://docs.scrapecreators.com/v1/tiktok/search/users/),
|
|
108
|
+
[YouTube](https://docs.scrapecreators.com/v1/youtube/search/).
|
|
@@ -55,51 +55,96 @@ module ScrapeCreators
|
|
|
55
55
|
end
|
|
56
56
|
end
|
|
57
57
|
|
|
58
|
-
def
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
58
|
+
def search_users(query, source: INSTAGRAM)
|
|
59
|
+
query = query.to_s.strip
|
|
60
|
+
raise ArgumentError, 'Search query is required' if query.empty?
|
|
61
|
+
|
|
62
|
+
users = case source.to_s
|
|
63
|
+
when INSTAGRAM
|
|
64
|
+
response = get('/v1/instagram/search', query: query)
|
|
65
|
+
search_results(response).dig('data', 'users').to_a.map do |user|
|
|
66
|
+
{ handle: user['username'], name: user['full_name'], avatar_url: user['profile_pic_url'] }
|
|
67
|
+
end
|
|
68
|
+
when TIKTOK
|
|
69
|
+
response = get('/v1/tiktok/search/users', query: query, trim: true)
|
|
70
|
+
search_results(response).fetch('users', []).map do |user|
|
|
71
|
+
{ handle: user['unique_id'], name: user['nickname'], avatar_url: user.dig('avatar_medium', 'url_list', 0) }
|
|
72
|
+
end
|
|
73
|
+
when YOUTUBE
|
|
74
|
+
response = get('/v1/youtube/search', query: query, type: 'channels')
|
|
75
|
+
search_results(response).fetch('channels', []).map do |channel|
|
|
76
|
+
{ handle: channel['handle'], name: channel['title'], avatar_url: channel['thumbnail'] }
|
|
77
|
+
end
|
|
78
|
+
else
|
|
79
|
+
raise ArgumentError, "Unsupported search source: #{source}"
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
users.filter_map do |user|
|
|
83
|
+
handle = user[:handle].to_s.strip.delete_prefix('@')
|
|
84
|
+
next if handle.empty? || handle.match?(%r{[[:space:]/]})
|
|
85
|
+
|
|
86
|
+
user.merge(handle: handle)
|
|
87
|
+
end.uniq { |user| user[:handle].downcase }
|
|
65
88
|
end
|
|
66
89
|
|
|
67
|
-
def
|
|
90
|
+
def posts(handle, options = {})
|
|
68
91
|
source = (options[:source] || 'instagram').to_s
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
92
|
+
pages = options[:pages] || options[:max_pages] || 1
|
|
93
|
+
all_items = []
|
|
94
|
+
cursor = nil
|
|
95
|
+
has_more = true
|
|
96
|
+
|
|
97
|
+
pages.to_i.times do
|
|
98
|
+
break unless has_more
|
|
99
|
+
|
|
100
|
+
page = posts_page(handle, options.merge(cursor: cursor))
|
|
101
|
+
items = Array(page[:items])
|
|
102
|
+
all_items.concat(items)
|
|
103
|
+
cursor = page[:cursor]
|
|
104
|
+
has_more = page[:has_more] && !cursor.to_s.empty? && !items.empty?
|
|
105
|
+
end
|
|
74
106
|
|
|
75
|
-
|
|
76
|
-
comments_page(url_or_code, options)[:comments]
|
|
107
|
+
all_items
|
|
77
108
|
end
|
|
78
109
|
|
|
79
|
-
def
|
|
110
|
+
def posts_page(handle, options = {})
|
|
80
111
|
source = (options[:source] || 'instagram').to_s
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
params = { url: url }.merge(extra)
|
|
84
|
-
cursor = options[:cursor] || options['cursor'] || options[:continuationToken] || options['continuationToken'] || options[:continuation_token]
|
|
112
|
+
params = { handle: handle }
|
|
113
|
+
cursor = options[:cursor] || options['cursor']
|
|
85
114
|
if cursor && !cursor.to_s.empty?
|
|
86
|
-
|
|
87
|
-
params[:continuationToken] = cursor
|
|
88
|
-
else
|
|
89
|
-
params[:cursor] = cursor
|
|
90
|
-
end
|
|
115
|
+
params[posts_cursor_param_for(source:)] = cursor
|
|
91
116
|
end
|
|
117
|
+
extra = options.reject { |k, _| %w[source cursor max_pages pages].include?(k.to_s) }
|
|
118
|
+
params.merge!(extra)
|
|
92
119
|
|
|
93
|
-
res = get(
|
|
94
|
-
|
|
95
|
-
|
|
120
|
+
res = get(posts_url_for(source:), params)
|
|
121
|
+
key = posts_key_for(source:)
|
|
122
|
+
items = res.is_a?(Hash) ? (res[key] || []) : []
|
|
123
|
+
next_cursor = next_posts_cursor(res, source)
|
|
96
124
|
{
|
|
97
|
-
|
|
125
|
+
items: items,
|
|
98
126
|
cursor: next_cursor,
|
|
99
|
-
has_more:
|
|
127
|
+
has_more: posts_has_more?(res, source, next_cursor)
|
|
100
128
|
}
|
|
101
129
|
end
|
|
102
130
|
|
|
131
|
+
def post(url_or_code, options = {})
|
|
132
|
+
url = normalize_url(url_or_code)
|
|
133
|
+
source = options.dig(:source) || 'instagram'
|
|
134
|
+
post_url = post_url_for(source:)
|
|
135
|
+
params = { url: url }.merge(options)
|
|
136
|
+
get(post_url, params)
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def comments(url_or_code, options = {})
|
|
140
|
+
url = normalize_url(url_or_code)
|
|
141
|
+
params = { url: url }.merge(options)
|
|
142
|
+
source = options.dig(:source) || 'instagram'
|
|
143
|
+
comments_url = comments_url_for(source:)
|
|
144
|
+
res = get(comments_url, params)
|
|
145
|
+
res.is_a?(Hash) ? (res["comments"] || []) : []
|
|
146
|
+
end
|
|
147
|
+
|
|
103
148
|
def get(endpoint, params = {}, options = {})
|
|
104
149
|
request(endpoint, method: :get, params: params, options: options)
|
|
105
150
|
end
|
|
@@ -140,23 +185,41 @@ module ScrapeCreators
|
|
|
140
185
|
|
|
141
186
|
private
|
|
142
187
|
|
|
143
|
-
def
|
|
188
|
+
def search_results(response)
|
|
189
|
+
unless response.is_a?(Hash) && response['success'] != false
|
|
190
|
+
raise APIError, 'Account search failed'
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
response
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def posts_cursor_param_for(source:)
|
|
197
|
+
case source.to_s
|
|
198
|
+
when TIKTOK then 'max_cursor'
|
|
199
|
+
when INSTAGRAM then 'next_max_id'
|
|
200
|
+
when YOUTUBE then 'continuationToken'
|
|
201
|
+
else 'cursor'
|
|
202
|
+
end
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
def next_posts_cursor(res, source)
|
|
144
206
|
return nil unless res.is_a?(Hash)
|
|
145
207
|
|
|
146
|
-
cursor =
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
208
|
+
cursor = case source.to_s
|
|
209
|
+
when TIKTOK then res['max_cursor']
|
|
210
|
+
when INSTAGRAM then res['next_max_id']
|
|
211
|
+
when YOUTUBE then res['continuationToken'] || res['continuation_token']
|
|
212
|
+
else res['cursor']
|
|
150
213
|
end
|
|
151
|
-
return nil if cursor.nil? || cursor.to_s.empty?
|
|
214
|
+
return nil if cursor.nil? || cursor.to_s.empty? || cursor.to_s == '0'
|
|
152
215
|
|
|
153
216
|
cursor
|
|
154
217
|
end
|
|
155
218
|
|
|
156
|
-
def
|
|
219
|
+
def posts_has_more?(res, source, next_cursor)
|
|
157
220
|
return false unless res.is_a?(Hash)
|
|
158
221
|
|
|
159
|
-
if source == TIKTOK
|
|
222
|
+
if source.to_s == TIKTOK
|
|
160
223
|
res['has_more'].to_i == 1
|
|
161
224
|
else
|
|
162
225
|
!next_cursor.nil?
|