threads_client_ruby 0.1.0 → 0.1.1
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 +8 -0
- data/README.md +32 -8
- data/lib/threads_client_ruby/version.rb +1 -1
- data/lib/threads_client_ruby.rb +135 -25
- data/logo.jpg +0 -0
- metadata +2 -2
- data/.rubocop.yml +0 -13
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 1e8060fb1b838946566a4d2cdab8abbf121811cb52e4b6794c474e2e4adcb28d
|
|
4
|
+
data.tar.gz: 5b2159ab1d749c87f99c878b5306ee731ab907af1045217db72baf284d307b94
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 297041296573f466dd33003b9e750946b6c980779860bdf4db22a9821a3fd51966a238fdf5a475f34ef2b72a797cfe900bf68861ef8952846d79df3059329591
|
|
7
|
+
data.tar.gz: 4d5e56871d0795bbffb0917f58a82f07c2255406a60083c18bd625e1f47c541b321092ce3b7f3bd74592ee370e6f28b6728dfb78d146f9e8302a1f1a50bd7d54
|
data/CHANGELOG.md
CHANGED
data/README.md
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
# [<img src="logo.jpg" width="36" height="36" />](https://github.com/
|
|
1
|
+
# [<img src="logo.jpg" width="36" height="36" />](https://github.com/dereknguyen269) Threads Client Ruby
|
|
2
2
|
|
|
3
3
|
> Unofficial, Reverse-Engineered Ruby client for Meta's [Threads](https://threads.net).
|
|
4
4
|
|
|
@@ -30,7 +30,7 @@ credentials = {
|
|
|
30
30
|
password: "Instagram's password"
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
-
|
|
33
|
+
ThreadsClientRuby.config do |config|
|
|
34
34
|
config.credentials = credentials
|
|
35
35
|
end
|
|
36
36
|
```
|
|
@@ -42,7 +42,7 @@ credentials = {
|
|
|
42
42
|
userid: "Instagram's user id"
|
|
43
43
|
}
|
|
44
44
|
|
|
45
|
-
|
|
45
|
+
ThreadsClientRuby.config do |config|
|
|
46
46
|
config.credentials = credentials
|
|
47
47
|
end
|
|
48
48
|
```
|
|
@@ -52,7 +52,7 @@ end
|
|
|
52
52
|
#### 🤖 Get usertoken and userid
|
|
53
53
|
|
|
54
54
|
```ruby
|
|
55
|
-
userinfo =
|
|
55
|
+
userinfo = ThreadsClientRuby.get_userinfo
|
|
56
56
|
# {
|
|
57
57
|
# :usertoken=> "eyJkc191c2VyX2lkIjoiNTgzOTIyMTY....",
|
|
58
58
|
# :userid=>"583922..."
|
|
@@ -62,9 +62,10 @@ end
|
|
|
62
62
|
#### 📌 Features
|
|
63
63
|
|
|
64
64
|
- [x] ✅ [Text Threads](#✨-threads-with-image)
|
|
65
|
-
- [
|
|
66
|
-
- [
|
|
67
|
-
- [
|
|
65
|
+
- [x] ✅ [Threads with Image](#✨-threads-with-image)
|
|
66
|
+
- [x] ✅ [Threads with Link Attachment](#✨-threads-with-link-attachment)
|
|
67
|
+
- [x] ✅ [Get Post Id From URL](#✨-get-post-id-from-url)
|
|
68
|
+
- [x] ✅ [Reply to Other Threads](#✨-reply-to-other-threads)
|
|
68
69
|
- [ ] ✅ [Like/Unlike a Thread](#✨-likeunlike-a-thread)
|
|
69
70
|
- [ ] ✅ [Follow/Unfollow a User](#✨-followunfollow-a-user)
|
|
70
71
|
- [ ] ✅ [Delete a Post](#✨-delete-a-post)
|
|
@@ -72,15 +73,38 @@ end
|
|
|
72
73
|
**✨ Text Threads**
|
|
73
74
|
|
|
74
75
|
```ruby
|
|
75
|
-
|
|
76
|
+
ThreadsClientRuby.publish(text: 'Hello World!')
|
|
76
77
|
```
|
|
77
78
|
|
|
78
79
|
###### ✨ Threads with Image
|
|
79
80
|
|
|
81
|
+
```ruby
|
|
82
|
+
# Online image path
|
|
83
|
+
ThreadsClientRuby.publish(text: 'Hello World!', image: 'https://fastly.picsum.photos/id/654/536/354.jpg?hmac=Nqd_oi3EIiPJBAVPYhIUjaEvKpRqLjhtTHkxPmjjo7M')
|
|
84
|
+
|
|
85
|
+
# Or with local image path
|
|
86
|
+
ThreadsClientRuby.publish(text: 'Hello World!', image: '/Users/local-path/logo.jpg')
|
|
87
|
+
```
|
|
88
|
+
|
|
80
89
|
###### ✨ Threads with Link Attachment
|
|
81
90
|
|
|
91
|
+
```ruby
|
|
92
|
+
ThreadsClientRuby.publish(url: 'https://github.com/dereknguyen269/threads_client_ruby')
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
###### ✨ Get Post Id From URL
|
|
96
|
+
|
|
97
|
+
```ruby
|
|
98
|
+
post_id = ThreadsClientRuby.get_post_id('https://www.threads.net/t/CugF-EjhQ3r')
|
|
99
|
+
# => 3143538795635609067
|
|
100
|
+
```
|
|
101
|
+
|
|
82
102
|
###### ✨ Reply to Other Threads
|
|
83
103
|
|
|
104
|
+
```ruby
|
|
105
|
+
ThreadsClientRuby.publish(reply_id: post_id, text: "Reply to #{post_id}")
|
|
106
|
+
```
|
|
107
|
+
|
|
84
108
|
###### ✨ Like/Unlike a Thread
|
|
85
109
|
|
|
86
110
|
###### ✨ Follow/Unfollow a User
|
data/lib/threads_client_ruby.rb
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require_relative "threads_client_ruby/version"
|
|
4
|
+
require 'securerandom'
|
|
4
5
|
require 'net/http'
|
|
5
6
|
require 'uri'
|
|
6
7
|
require 'json'
|
|
8
|
+
require 'httparty'
|
|
9
|
+
require 'mime/types'
|
|
7
10
|
|
|
8
11
|
module ThreadsClientRuby
|
|
9
12
|
DEFAULT_DEVICE_ID = "android-#{rand(36**24).to_s(36)}"
|
|
@@ -11,6 +14,7 @@ module ThreadsClientRuby
|
|
|
11
14
|
BASE_API_URL = 'https://i.instagram.com/api/v1'
|
|
12
15
|
LOGIN_URL = BASE_API_URL + '/bloks/apps/com.bloks.www.bloks.caa.login.async.send_login_request/'
|
|
13
16
|
POST_URL = BASE_API_URL + '/media/configure_text_only_post/'
|
|
17
|
+
POST_WITH_IMAGE_URL = BASE_API_URL + '/media/configure_text_post_app_feed/'
|
|
14
18
|
DEFAULT_LSD_TOKEN = 'NjppQDEgONsU_1LCzrmp6q'
|
|
15
19
|
class Error < StandardError; end
|
|
16
20
|
module Config
|
|
@@ -36,53 +40,88 @@ module ThreadsClientRuby
|
|
|
36
40
|
core.user_info
|
|
37
41
|
end
|
|
38
42
|
|
|
43
|
+
# available key for options:
|
|
44
|
+
# - text
|
|
45
|
+
# - image
|
|
46
|
+
# - url
|
|
47
|
+
# - reply_id
|
|
39
48
|
def self.publish(options = {})
|
|
40
49
|
core = ThreadsClientRuby::Core.new ThreadsClientRuby::Config.credentials
|
|
41
|
-
|
|
42
|
-
elsif options[:text]
|
|
43
|
-
core.publish(options)
|
|
44
|
-
elsif options[:image]
|
|
45
|
-
else
|
|
46
|
-
raise Error.new "Don't have text or image"
|
|
47
|
-
end
|
|
50
|
+
core.publish(options)
|
|
48
51
|
end
|
|
49
52
|
|
|
53
|
+
def self.get_post_id_from_url(post_url, options = {})
|
|
54
|
+
uri = URI.parse(post_url)
|
|
55
|
+
response = Net::HTTP.get_response(uri)
|
|
56
|
+
text = response.body.to_s.gsub(/\s+/, '').gsub(/\n+/, '')
|
|
57
|
+
|
|
58
|
+
post_id = text.match(/{"post_id":"(.*?)"}/)&.captures&.first
|
|
59
|
+
lsd_token = text.match(/"LSD",\[\],{"token":"(\w+)"},\d+\]/)&.captures&.first
|
|
60
|
+
post_id
|
|
61
|
+
end
|
|
62
|
+
|
|
50
63
|
class Core
|
|
51
|
-
def initialize(credentials)
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
64
|
+
def initialize(credentials = {})
|
|
65
|
+
if credentials.is_a?(Hash)
|
|
66
|
+
@username = credentials[:username]
|
|
67
|
+
@password = credentials[:password]
|
|
68
|
+
@user_token = credentials[:usertoken]
|
|
69
|
+
@user_id = credentials[:userid]
|
|
70
|
+
else
|
|
71
|
+
raise "Invalid credentials"
|
|
72
|
+
end
|
|
56
73
|
end
|
|
57
74
|
|
|
58
75
|
def publish(options)
|
|
76
|
+
req_post_url = ThreadsClientRuby::POST_URL
|
|
77
|
+
data = default_req_params(options)
|
|
78
|
+
if options[:image]
|
|
79
|
+
req_post_url = ThreadsClientRuby::POST_WITH_IMAGE_URL
|
|
80
|
+
data = req_params_with_image(data, options[:image])
|
|
81
|
+
else
|
|
82
|
+
data[:publish_mode] = 'text_post'
|
|
83
|
+
end
|
|
84
|
+
if options[:url] || options[:reply_id]
|
|
85
|
+
data[:text_post_app_info] = {}
|
|
86
|
+
data[:text_post_app_info][:link_attachment_url] = options[:url] if options[:url]
|
|
87
|
+
data[:text_post_app_info][:reply_id] = options[:reply_id] if options[:reply_id]
|
|
88
|
+
end
|
|
89
|
+
url = URI.parse(req_post_url)
|
|
90
|
+
headers = get_app_headers
|
|
91
|
+
payload = "signed_body=SIGNATURE.#{URI.encode_www_form_component(JSON.generate(data))}"
|
|
92
|
+
response = HTTParty.post(url, headers: headers, body: payload)
|
|
93
|
+
p response_handler(response)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def user_info
|
|
97
|
+
{ usertoken: user_token, userid: user_id }
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
private
|
|
101
|
+
|
|
102
|
+
def default_req_params(options)
|
|
59
103
|
now = Time.now
|
|
60
104
|
timezone_offset = -now.utc_offset
|
|
61
|
-
|
|
62
|
-
data = {
|
|
105
|
+
{
|
|
63
106
|
text_post_app_info: { reply_control: 0 },
|
|
64
107
|
timezone_offset: timezone_offset.to_s,
|
|
65
108
|
source_type: '4',
|
|
66
109
|
_uid: user_id,
|
|
67
110
|
device_id: DEFAULT_DEVICE_ID,
|
|
68
|
-
caption: options[:text] || '
|
|
111
|
+
caption: options[:text] || '',
|
|
69
112
|
upload_id: now.to_i,
|
|
70
113
|
device: androidDevice
|
|
71
114
|
}
|
|
72
|
-
data[:publish_mode] = 'text_post'
|
|
73
|
-
url = URI.parse(ThreadsClientRuby::POST_URL)
|
|
74
|
-
headers = get_app_headers
|
|
75
|
-
payload = "signed_body=SIGNATURE.#{URI.encode_www_form_component(JSON.generate(data))}"
|
|
76
|
-
response = HTTParty.post(url, headers: headers, body: payload)
|
|
77
|
-
p response_handler(response)
|
|
78
115
|
end
|
|
79
116
|
|
|
80
|
-
def
|
|
81
|
-
|
|
117
|
+
def req_params_with_image(data, image)
|
|
118
|
+
upload_id = Time.now.to_i.to_s
|
|
119
|
+
upload_image(image, upload_id)
|
|
120
|
+
data[:upload_id] = upload_id
|
|
121
|
+
data[:scene_capture_type] = ''
|
|
122
|
+
data
|
|
82
123
|
end
|
|
83
124
|
|
|
84
|
-
private
|
|
85
|
-
|
|
86
125
|
def androidDevice
|
|
87
126
|
{
|
|
88
127
|
manufacturer: 'OnePlus',
|
|
@@ -189,5 +228,76 @@ module ThreadsClientRuby
|
|
|
189
228
|
response_body = JSON.parse(response.body)
|
|
190
229
|
{ status: true, response_body: response_body }
|
|
191
230
|
end
|
|
231
|
+
|
|
232
|
+
def upload_image(image, upload_id)
|
|
233
|
+
name = "#{upload_id}_0_#{SecureRandom.random_number(10**10 - 10**9 + 1) + 10**9}"
|
|
234
|
+
url = "https://www.instagram.com/rupload_igphoto/#{name}"
|
|
235
|
+
|
|
236
|
+
content = nil
|
|
237
|
+
mime_type = nil
|
|
238
|
+
|
|
239
|
+
if image.is_a?(String) || image.key?(:path)
|
|
240
|
+
image_path = image.is_a?(String) ? image : image[:path]
|
|
241
|
+
is_file_path = !image_path.start_with?('http')
|
|
242
|
+
|
|
243
|
+
if is_file_path
|
|
244
|
+
content = File.binread(image_path)
|
|
245
|
+
mime_type = MIME::Types.type_for(image_path).first.to_s
|
|
246
|
+
else
|
|
247
|
+
image_uri = URI.parse(image_path)
|
|
248
|
+
response = Net::HTTP.get_response(image_uri)
|
|
249
|
+
if response.is_a?(Net::HTTPSuccess)
|
|
250
|
+
content = response.body
|
|
251
|
+
mime_type = response['content-type']
|
|
252
|
+
end
|
|
253
|
+
end
|
|
254
|
+
else
|
|
255
|
+
content = image[:data]
|
|
256
|
+
mime_type = image[:type].include?('/') ? image[:type] : MIME::Types.type_for(image[:type]).first.to_s
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
x_instagram_rupload_params = {
|
|
260
|
+
upload_id: upload_id,
|
|
261
|
+
media_type: '1',
|
|
262
|
+
sticker_burnin_params: JSON.generate([]),
|
|
263
|
+
image_compression: JSON.generate({ lib_name: 'moz', lib_version: '3.1.m', quality: '80' }),
|
|
264
|
+
xsharing_user_ids: JSON.generate([]),
|
|
265
|
+
retry_context: JSON.generate({
|
|
266
|
+
num_step_auto_retry: '0',
|
|
267
|
+
num_reupload: '0',
|
|
268
|
+
num_step_manual_retry: '0',
|
|
269
|
+
}),
|
|
270
|
+
'IG-FB-Xpost-entry-point-v2': 'feed',
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
content_length = content.length
|
|
274
|
+
image_headers = get_default_headers(@username).merge({
|
|
275
|
+
'Content-Type': 'application/octet-stream',
|
|
276
|
+
'X_FB_PHOTO_WATERFALL_ID': SecureRandom.uuid,
|
|
277
|
+
'X-Entity-Type': mime_type ? "image/#{mime_type}" : 'image/jpeg',
|
|
278
|
+
'Offset': '0',
|
|
279
|
+
'X-Instagram-Rupload-Params': JSON.generate(x_instagram_rupload_params),
|
|
280
|
+
'X-Entity-Name': name,
|
|
281
|
+
'X-Entity-Length': content_length.to_s,
|
|
282
|
+
'Content-Length': content_length.to_s,
|
|
283
|
+
'Accept-Encoding': 'gzip',
|
|
284
|
+
})
|
|
285
|
+
|
|
286
|
+
begin
|
|
287
|
+
response = Net::HTTP.start(URI(url).hostname, URI(url).port, use_ssl: true) do |http|
|
|
288
|
+
request = Net::HTTP::Post.new(URI(url).request_uri, image_headers)
|
|
289
|
+
request.body = content
|
|
290
|
+
http.request(request)
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
data = JSON.parse(response.body)
|
|
294
|
+
data
|
|
295
|
+
rescue StandardError => e
|
|
296
|
+
puts "[UPLOAD_IMAGE] FAILED: #{e.response.body}"
|
|
297
|
+
raise e
|
|
298
|
+
end
|
|
299
|
+
end
|
|
192
300
|
end
|
|
193
301
|
end
|
|
302
|
+
|
|
303
|
+
# ThreadsClientRuby.publish(text: 'Hello World!', image: '/Users/quan/Products/threads-api/logo.jpg')
|
data/logo.jpg
ADDED
|
Binary file
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: threads_client_ruby
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.1.
|
|
4
|
+
version: 0.1.1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Derek Nguyen
|
|
@@ -32,7 +32,6 @@ executables: []
|
|
|
32
32
|
extensions: []
|
|
33
33
|
extra_rdoc_files: []
|
|
34
34
|
files:
|
|
35
|
-
- ".rubocop.yml"
|
|
36
35
|
- CHANGELOG.md
|
|
37
36
|
- CODE_OF_CONDUCT.md
|
|
38
37
|
- Gemfile
|
|
@@ -43,6 +42,7 @@ files:
|
|
|
43
42
|
- bin/setup
|
|
44
43
|
- lib/threads_client_ruby.rb
|
|
45
44
|
- lib/threads_client_ruby/version.rb
|
|
45
|
+
- logo.jpg
|
|
46
46
|
- sig/threads_client_ruby.rbs
|
|
47
47
|
homepage: https://github.com/dereknguyen269/threads_client_ruby
|
|
48
48
|
licenses:
|
data/.rubocop.yml
DELETED