itch_client 0.2.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 +7 -0
- data/.github/workflows/main.yml +16 -0
- data/.gitignore +12 -0
- data/.rspec +3 -0
- data/.rubocop.yml +15 -0
- data/CHANGELOG.md +5 -0
- data/Gemfile +14 -0
- data/Gemfile.lock +96 -0
- data/LICENSE.txt +21 -0
- data/README.md +268 -0
- data/Rakefile +12 -0
- data/bin/console +25 -0
- data/bin/setup +8 -0
- data/itch_client.gemspec +27 -0
- data/lib/itch/auth.rb +105 -0
- data/lib/itch/client.rb +42 -0
- data/lib/itch/game.rb +136 -0
- data/lib/itch/game_map.rb +69 -0
- data/lib/itch/purchases.rb +43 -0
- data/lib/itch/request.rb +29 -0
- data/lib/itch/require_auth.rb +16 -0
- data/lib/itch/reward.rb +26 -0
- data/lib/itch/rewards.rb +125 -0
- data/lib/itch/simple_inspect.rb +22 -0
- data/lib/itch/version.rb +5 -0
- data/lib/itch_client.rb +45 -0
- metadata +85 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 9e207ec3ac07a0a26dd3315a9e0cf1a9e0e6440559b07dffbc2f673aaac24231
|
|
4
|
+
data.tar.gz: f2165aebc46c8486531bf679d03efbd2c0c5104b6854369031f69375088b9cf4
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: feb4a98fcfd03fee02025981aa54a242cec85d4122fb74eede970d3b189c580c0898b5fea59c795e63beefb36aaf6bd5714ca8fb56406f5ed2c7812c9bdf452f
|
|
7
|
+
data.tar.gz: 48b5f07013f2e6b8435d8dafb0c708ff3b97f93d4fa4273f30024a838299c2b5c9532f669b6d6025c94b425bcdf99faaada80fcf30856c38c427e6b32cda0e33
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
name: Ruby
|
|
2
|
+
|
|
3
|
+
on: [push,pull_request]
|
|
4
|
+
|
|
5
|
+
jobs:
|
|
6
|
+
build:
|
|
7
|
+
runs-on: ubuntu-latest
|
|
8
|
+
steps:
|
|
9
|
+
- uses: actions/checkout@v2
|
|
10
|
+
- name: Set up Ruby
|
|
11
|
+
uses: ruby/setup-ruby@v1
|
|
12
|
+
with:
|
|
13
|
+
ruby-version: 3.0.1
|
|
14
|
+
bundler-cache: true
|
|
15
|
+
- name: Run the default task
|
|
16
|
+
run: bundle exec rake
|
data/.gitignore
ADDED
data/.rspec
ADDED
data/.rubocop.yml
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
AllCops:
|
|
2
|
+
TargetRubyVersion: 2.7
|
|
3
|
+
SuggestExtensions: false
|
|
4
|
+
NewCops: enable
|
|
5
|
+
|
|
6
|
+
Style/StringLiterals:
|
|
7
|
+
Enabled: true
|
|
8
|
+
EnforcedStyle: double_quotes
|
|
9
|
+
|
|
10
|
+
Style/StringLiteralsInInterpolation:
|
|
11
|
+
Enabled: true
|
|
12
|
+
EnforcedStyle: double_quotes
|
|
13
|
+
|
|
14
|
+
Layout/LineLength:
|
|
15
|
+
Max: 120
|
data/CHANGELOG.md
ADDED
data/Gemfile
ADDED
data/Gemfile.lock
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
PATH
|
|
2
|
+
remote: .
|
|
3
|
+
specs:
|
|
4
|
+
itch_client (0.2.0)
|
|
5
|
+
mechanize (~> 2.8)
|
|
6
|
+
|
|
7
|
+
GEM
|
|
8
|
+
remote: https://rubygems.org/
|
|
9
|
+
specs:
|
|
10
|
+
addressable (2.7.0)
|
|
11
|
+
public_suffix (>= 2.0.2, < 5.0)
|
|
12
|
+
ast (2.4.2)
|
|
13
|
+
connection_pool (2.2.5)
|
|
14
|
+
diff-lcs (1.4.4)
|
|
15
|
+
domain_name (0.5.20190701)
|
|
16
|
+
unf (>= 0.0.5, < 1.0.0)
|
|
17
|
+
http-cookie (1.0.3)
|
|
18
|
+
domain_name (~> 0.5)
|
|
19
|
+
io-console (0.5.9)
|
|
20
|
+
irb (1.3.5)
|
|
21
|
+
reline (>= 0.1.5)
|
|
22
|
+
mechanize (2.8.1)
|
|
23
|
+
addressable (~> 2.7)
|
|
24
|
+
domain_name (~> 0.5, >= 0.5.20190701)
|
|
25
|
+
http-cookie (~> 1.0, >= 1.0.3)
|
|
26
|
+
mime-types (~> 3.0)
|
|
27
|
+
net-http-digest_auth (~> 1.4, >= 1.4.1)
|
|
28
|
+
net-http-persistent (>= 2.5.2, < 5.0.dev)
|
|
29
|
+
nokogiri (~> 1.11, >= 1.11.2)
|
|
30
|
+
rubyntlm (~> 0.6, >= 0.6.3)
|
|
31
|
+
webrick (~> 1.7)
|
|
32
|
+
webrobots (~> 0.1.2)
|
|
33
|
+
mime-types (3.3.1)
|
|
34
|
+
mime-types-data (~> 3.2015)
|
|
35
|
+
mime-types-data (3.2021.0225)
|
|
36
|
+
net-http-digest_auth (1.4.1)
|
|
37
|
+
net-http-persistent (4.0.1)
|
|
38
|
+
connection_pool (~> 2.2)
|
|
39
|
+
nokogiri (1.11.5-x86_64-linux)
|
|
40
|
+
racc (~> 1.4)
|
|
41
|
+
parallel (1.20.1)
|
|
42
|
+
parser (3.0.1.1)
|
|
43
|
+
ast (~> 2.4.1)
|
|
44
|
+
public_suffix (4.0.6)
|
|
45
|
+
racc (1.5.2)
|
|
46
|
+
rainbow (3.0.0)
|
|
47
|
+
rake (13.0.3)
|
|
48
|
+
regexp_parser (2.1.1)
|
|
49
|
+
reline (0.2.5)
|
|
50
|
+
io-console (~> 0.5)
|
|
51
|
+
rexml (3.2.5)
|
|
52
|
+
rspec (3.10.0)
|
|
53
|
+
rspec-core (~> 3.10.0)
|
|
54
|
+
rspec-expectations (~> 3.10.0)
|
|
55
|
+
rspec-mocks (~> 3.10.0)
|
|
56
|
+
rspec-core (3.10.1)
|
|
57
|
+
rspec-support (~> 3.10.0)
|
|
58
|
+
rspec-expectations (3.10.1)
|
|
59
|
+
diff-lcs (>= 1.2.0, < 2.0)
|
|
60
|
+
rspec-support (~> 3.10.0)
|
|
61
|
+
rspec-mocks (3.10.2)
|
|
62
|
+
diff-lcs (>= 1.2.0, < 2.0)
|
|
63
|
+
rspec-support (~> 3.10.0)
|
|
64
|
+
rspec-support (3.10.2)
|
|
65
|
+
rubocop (1.15.0)
|
|
66
|
+
parallel (~> 1.10)
|
|
67
|
+
parser (>= 3.0.0.0)
|
|
68
|
+
rainbow (>= 2.2.2, < 4.0)
|
|
69
|
+
regexp_parser (>= 1.8, < 3.0)
|
|
70
|
+
rexml
|
|
71
|
+
rubocop-ast (>= 1.5.0, < 2.0)
|
|
72
|
+
ruby-progressbar (~> 1.7)
|
|
73
|
+
unicode-display_width (>= 1.4.0, < 3.0)
|
|
74
|
+
rubocop-ast (1.5.0)
|
|
75
|
+
parser (>= 3.0.1.1)
|
|
76
|
+
ruby-progressbar (1.11.0)
|
|
77
|
+
rubyntlm (0.6.3)
|
|
78
|
+
unf (0.1.4)
|
|
79
|
+
unf_ext
|
|
80
|
+
unf_ext (0.0.7.7)
|
|
81
|
+
unicode-display_width (2.0.0)
|
|
82
|
+
webrick (1.7.0)
|
|
83
|
+
webrobots (0.1.2)
|
|
84
|
+
|
|
85
|
+
PLATFORMS
|
|
86
|
+
x86_64-linux
|
|
87
|
+
|
|
88
|
+
DEPENDENCIES
|
|
89
|
+
irb (~> 1.3)
|
|
90
|
+
itch_client!
|
|
91
|
+
rake (~> 13.0)
|
|
92
|
+
rspec (~> 3.0)
|
|
93
|
+
rubocop (~> 1.7)
|
|
94
|
+
|
|
95
|
+
BUNDLED WITH
|
|
96
|
+
2.2.17
|
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
The MIT License (MIT)
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2021 Billiam
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in
|
|
13
|
+
all copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
|
21
|
+
THE SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
# Itch Client
|
|
2
|
+
|
|
3
|
+
An itch.io screenscraping and automation utility.
|
|
4
|
+
|
|
5
|
+
Originally written to help automate community copy rewards based on purchases and tips.
|
|
6
|
+
|
|
7
|
+
## Features
|
|
8
|
+
|
|
9
|
+
* Log in and save cookies
|
|
10
|
+
* Fetch and parse purchase CSV data, optionally by date
|
|
11
|
+
* Fetch and parse reward redemption CSV data
|
|
12
|
+
* Fetch reward data
|
|
13
|
+
* Add/update/delete rewards
|
|
14
|
+
* Fetch and update game theme and custom CSS data
|
|
15
|
+
|
|
16
|
+
## How fragile is it?
|
|
17
|
+
|
|
18
|
+
Very fragile. Enjoy it while it lasts!
|
|
19
|
+
|
|
20
|
+
## Installation
|
|
21
|
+
|
|
22
|
+
Add this line to your application's Gemfile:
|
|
23
|
+
|
|
24
|
+
```ruby
|
|
25
|
+
gem 'itch_client'
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
And then execute:
|
|
29
|
+
|
|
30
|
+
$ bundle install
|
|
31
|
+
|
|
32
|
+
Or install it yourself as:
|
|
33
|
+
|
|
34
|
+
$ gem install itch_client
|
|
35
|
+
|
|
36
|
+
## Usage
|
|
37
|
+
|
|
38
|
+
### Authentication
|
|
39
|
+
|
|
40
|
+
The itch client requires a username and password. It doesn't handle captchas, so ironically, 2fa must be enabled.
|
|
41
|
+
|
|
42
|
+
```ruby
|
|
43
|
+
client = Itch.new(username: ENV['itch_username'], password: ENV['itch_password'], cookie_path: "./cookies.yml")
|
|
44
|
+
client.totp = -> { puts "Enter your 2fa code: \n"; gets.chomp }
|
|
45
|
+
client.logged_in?
|
|
46
|
+
#=> false
|
|
47
|
+
client.login
|
|
48
|
+
client.logged_in?
|
|
49
|
+
#=> true
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
After logging in, a cookie file will be saved at the `cookie_path` location if provided, and it won't be necessary to log in again until the cookie expires.
|
|
53
|
+
|
|
54
|
+
## Purchases
|
|
55
|
+
|
|
56
|
+
Fetch purchases csv data
|
|
57
|
+
|
|
58
|
+
```ruby
|
|
59
|
+
data = client.purchases.history
|
|
60
|
+
#=> #<CSV io_type:Hash encoding:ASCII-8BIT lineno:0 col_sep:"," row_sep:"\n" quote_char:"\"" headers:true>
|
|
61
|
+
data.each do |row|
|
|
62
|
+
row.to_h
|
|
63
|
+
#=> {
|
|
64
|
+
# "id"=>"123456",
|
|
65
|
+
# "object_name"=>"MyGame",
|
|
66
|
+
# "amount"=>"3.00",
|
|
67
|
+
# "source"=>"paypal",
|
|
68
|
+
# "created_at"=>"2016-01-07 23:41:31 UTC",
|
|
69
|
+
# "email"=>"email@example.com",
|
|
70
|
+
# "full_name"=>nil,
|
|
71
|
+
# "donation"=>"false",
|
|
72
|
+
# "on_sale"=>"false",
|
|
73
|
+
# "country_code"=>"CA",
|
|
74
|
+
# "ip"=>"0.0.0.0",
|
|
75
|
+
# "product_price"=>"0",
|
|
76
|
+
# "tax_added"=>"0.00",
|
|
77
|
+
# "tip"=>"3.00",
|
|
78
|
+
# "marketplace_fee"=>"0.30",
|
|
79
|
+
# "source_fee"=>"0.40",
|
|
80
|
+
# "payout"=>"payout_paid",
|
|
81
|
+
# "amount_delivered"=>"2.00",
|
|
82
|
+
# "currency"=>"USD",
|
|
83
|
+
# "source_id"=>"PAYID-ABCDEFGHIJKLMNOP",
|
|
84
|
+
# "billing_name"=>nil,
|
|
85
|
+
# "billing_street_1"=>nil,
|
|
86
|
+
# "billing_street_2"=>nil,
|
|
87
|
+
# "billing_city"=>nil,
|
|
88
|
+
# "billing_state"=>nil,
|
|
89
|
+
# "billing_zip"=>nil,
|
|
90
|
+
# "billing_country"=>nil
|
|
91
|
+
# }
|
|
92
|
+
end
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
Fetch one year of purchases
|
|
96
|
+
|
|
97
|
+
```ruby
|
|
98
|
+
client.purchases.history_by_year(2021)
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
Fetch one month of purchases
|
|
102
|
+
|
|
103
|
+
```ruby
|
|
104
|
+
client.purchases.history_by_month(5, 2019)
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
## Games
|
|
108
|
+
|
|
109
|
+
Find game by ID
|
|
110
|
+
|
|
111
|
+
```ruby
|
|
112
|
+
client.game(12345)
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
Find game by name
|
|
116
|
+
|
|
117
|
+
```ruby
|
|
118
|
+
client.game(name: 'MyItchGame')
|
|
119
|
+
client.id
|
|
120
|
+
# => 12345
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
### Game theme
|
|
124
|
+
|
|
125
|
+
Fetch game theme information
|
|
126
|
+
|
|
127
|
+
```ruby
|
|
128
|
+
client.game(12345).theme
|
|
129
|
+
#=> {
|
|
130
|
+
# "link_color"=>"#00ff00",
|
|
131
|
+
# "screenshots_loc"=>"hidden",
|
|
132
|
+
# "button_color"=>"#00ff00",
|
|
133
|
+
# "banner_position"=>"align_center",
|
|
134
|
+
# "background_repeat"=>"repeat-x",
|
|
135
|
+
# "default_screenshots_loc"=>"sidebar",
|
|
136
|
+
# "background_position"=>"align_right",
|
|
137
|
+
# "header_font_family"=>"serif",
|
|
138
|
+
# "background_image"=>
|
|
139
|
+
# {"url"=>"https://itch.io/dashboard/upload-image?upload_id=54321",
|
|
140
|
+
# "thumb_url"=>"https://img.itch.zone/aBcDeFgHiJkLmNoPqRsTuVwXyZ/original/aBcDe.png",
|
|
141
|
+
# "id"=>6027863},
|
|
142
|
+
# "font_size"=>"large",
|
|
143
|
+
# "bg_color"=>"#00ff00",
|
|
144
|
+
# "header_text_color"=>"#00ff00",
|
|
145
|
+
# "css"=>"body { color: blue; }",
|
|
146
|
+
# "bg2_color"=>"#00ff00",
|
|
147
|
+
# "banner_image"=>
|
|
148
|
+
# {"url"=>"https://itch.io/dashboard/upload-image?upload_id=12345",
|
|
149
|
+
# "thumb_url"=>"https://img.itch.zone/aBcDeFgHiJkLmNoPqRsTuVwXyZ/original/aBcDe.png",
|
|
150
|
+
# "id"=>12345678},
|
|
151
|
+
# "text_color"=>"#00ff00",
|
|
152
|
+
# "font_family"=>"pixel"
|
|
153
|
+
# }
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
Update game theme information
|
|
157
|
+
|
|
158
|
+
```ruby
|
|
159
|
+
game = client.game(12345)
|
|
160
|
+
new_theme = game.theme
|
|
161
|
+
new_theme['button_color'] = "#cccccc"
|
|
162
|
+
new_theme['css'] = "body { background-color: orange; }"
|
|
163
|
+
game.theme = new_theme
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
CSS Shortcuts
|
|
167
|
+
|
|
168
|
+
```ruby
|
|
169
|
+
client.game(12345).css
|
|
170
|
+
#=> body { background-color: orange; }
|
|
171
|
+
client.game(12345).css = "body { background-color: green; }"
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
### Rewards
|
|
175
|
+
|
|
176
|
+
Fetch reward CSV data
|
|
177
|
+
|
|
178
|
+
```ruby
|
|
179
|
+
client.game(12345).rewards.history
|
|
180
|
+
#=> #<CSV io_type:Hash encoding:ASCII-8BIT lineno:0 col_sep:"," row_sep:"\n" quote_char:"\"" headers:true>
|
|
181
|
+
data.each do |row|
|
|
182
|
+
row.to_h
|
|
183
|
+
#=>
|
|
184
|
+
# {
|
|
185
|
+
# "reward"=>"Free Community Copy",
|
|
186
|
+
# "date"=>"2021-05-21 06:56:34 UTC",
|
|
187
|
+
# "contact"=>nil,
|
|
188
|
+
# "fulfilled"=>"false",
|
|
189
|
+
# "shortcode"=>"VKDC-W4DD"
|
|
190
|
+
# }
|
|
191
|
+
end
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
#### Fetch current rewards
|
|
195
|
+
|
|
196
|
+
```ruby
|
|
197
|
+
client.game(12345).rewards.list
|
|
198
|
+
#=>
|
|
199
|
+
[
|
|
200
|
+
#<Itch::Reward:0x0000557aa98c7930
|
|
201
|
+
@amount=1,
|
|
202
|
+
@archived=false,
|
|
203
|
+
@claimed=0,
|
|
204
|
+
@description="<p>Hello. This is a reward description</p>",
|
|
205
|
+
@id=123456789,
|
|
206
|
+
@price="$5.00",
|
|
207
|
+
@title="My Reward">,
|
|
208
|
+
]
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
#### Modify a reward
|
|
212
|
+
|
|
213
|
+
Note: All rewards must be saved at once as follows, even if only one is being modified
|
|
214
|
+
|
|
215
|
+
```ruby
|
|
216
|
+
rewards = client.game(12345).rewards.list
|
|
217
|
+
rewards.first.amount
|
|
218
|
+
#=> 10
|
|
219
|
+
rewards.first.amount = 15
|
|
220
|
+
#=> 15
|
|
221
|
+
client.game(12345).rewards.save(rewards)
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
#### Add a reward
|
|
225
|
+
|
|
226
|
+
```ruby
|
|
227
|
+
rewards = client.game(12345).rewards.list
|
|
228
|
+
new_reward = Itch::Reward.new(
|
|
229
|
+
title: "My New Reward",
|
|
230
|
+
description: "<p>Full content of reward description</p>",
|
|
231
|
+
amount: 10,
|
|
232
|
+
price: "$10.00"
|
|
233
|
+
)
|
|
234
|
+
rewards << new_reward
|
|
235
|
+
client.game(12345).rewards.save(rewards)
|
|
236
|
+
```
|
|
237
|
+
|
|
238
|
+
#### Archive a reward
|
|
239
|
+
|
|
240
|
+
```ruby
|
|
241
|
+
rewards = client.game(12345).rewards.list
|
|
242
|
+
rewards.first.archived = true
|
|
243
|
+
client.game(12345).rewards.save(rewards)
|
|
244
|
+
```
|
|
245
|
+
|
|
246
|
+
#### Remove rewards
|
|
247
|
+
|
|
248
|
+
```ruby
|
|
249
|
+
rewards = client.game(12345).rewards.list
|
|
250
|
+
filtered_rewards = rewards.select do |reward|
|
|
251
|
+
reward.amount > 5
|
|
252
|
+
end
|
|
253
|
+
client.game(12345).rewards.save(filtered_rewards)
|
|
254
|
+
```
|
|
255
|
+
|
|
256
|
+
## Development
|
|
257
|
+
|
|
258
|
+
After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
|
|
259
|
+
|
|
260
|
+
To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and the created tag, and push the `.gem` file to [rubygems.org](https://rubygems.org).
|
|
261
|
+
|
|
262
|
+
## Contributing
|
|
263
|
+
|
|
264
|
+
Bug reports and pull requests are welcome on GitHub at https://github.com/Billiam/itch-client.
|
|
265
|
+
|
|
266
|
+
## License
|
|
267
|
+
|
|
268
|
+
The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
|
data/Rakefile
ADDED
data/bin/console
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "bundler/setup"
|
|
5
|
+
require "itch_client"
|
|
6
|
+
|
|
7
|
+
# You can add fixtures and/or initialization code here to make experimenting
|
|
8
|
+
# with your gem easier. You can also use a different console, if you like.
|
|
9
|
+
|
|
10
|
+
# (If you use this, don't forget to add pry to your Gemfile!)
|
|
11
|
+
# require "pry"
|
|
12
|
+
# Pry.start
|
|
13
|
+
|
|
14
|
+
require "irb"
|
|
15
|
+
|
|
16
|
+
def reload!
|
|
17
|
+
root_dir = File.expand_path("..", __dir__)
|
|
18
|
+
reload_dirs = %w[lib]
|
|
19
|
+
reload_dirs.each do |dir|
|
|
20
|
+
Dir.glob("#{root_dir}/#{dir}/**/*.rb").each { |f| load(f) }
|
|
21
|
+
end
|
|
22
|
+
puts "Reloaded"
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
IRB.start(__FILE__)
|
data/bin/setup
ADDED
data/itch_client.gemspec
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "lib/itch/version"
|
|
4
|
+
|
|
5
|
+
Gem::Specification.new do |spec|
|
|
6
|
+
spec.name = "itch_client"
|
|
7
|
+
spec.version = Itch::VERSION
|
|
8
|
+
spec.authors = ["Billiam"]
|
|
9
|
+
spec.email = ["billiamthesecond@gmail.com"]
|
|
10
|
+
|
|
11
|
+
spec.summary = "Itch.io screen scraping utility"
|
|
12
|
+
spec.homepage = "https://github.com/Billiam/itch-client"
|
|
13
|
+
spec.license = "MIT"
|
|
14
|
+
spec.required_ruby_version = Gem::Requirement.new(">= 2.7.0")
|
|
15
|
+
|
|
16
|
+
spec.metadata["homepage_uri"] = spec.homepage
|
|
17
|
+
spec.metadata["source_code_uri"] = spec.homepage
|
|
18
|
+
spec.metadata["changelog_uri"] = "#{spec.homepage}/CHANGELOG.md"
|
|
19
|
+
|
|
20
|
+
spec.files = Dir.chdir(File.expand_path(__dir__)) do
|
|
21
|
+
`git ls-files -z`.split("\x0").reject { |f| f.match(%r{\A(?:test|spec|features)/}) }
|
|
22
|
+
end
|
|
23
|
+
spec.bindir = "bin"
|
|
24
|
+
spec.require_paths = ["lib"]
|
|
25
|
+
|
|
26
|
+
spec.add_runtime_dependency "mechanize", "~> 2.8"
|
|
27
|
+
end
|
data/lib/itch/auth.rb
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "simple_inspect"
|
|
4
|
+
|
|
5
|
+
module Itch
|
|
6
|
+
# Authentication flow handler
|
|
7
|
+
class Auth
|
|
8
|
+
include SimpleInspect
|
|
9
|
+
|
|
10
|
+
attr_writer :username, :password, :totp
|
|
11
|
+
|
|
12
|
+
def initialize(agent, username: nil, password: nil, cookie_path: nil)
|
|
13
|
+
@agent = agent
|
|
14
|
+
@cookie_path = cookie_path
|
|
15
|
+
@username = username
|
|
16
|
+
@password = password
|
|
17
|
+
@totp = -> {}
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def logged_in?
|
|
21
|
+
@agent.get(Itch::URL::DASHBOARD).uri.to_s == Itch::URL::DASHBOARD
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def login
|
|
25
|
+
page = @agent.get(Itch::URL::LOGIN)
|
|
26
|
+
return unless page.code == "200"
|
|
27
|
+
raise AuthError, "Email and password are required for login" if @username.nil? || @password.nil?
|
|
28
|
+
|
|
29
|
+
page = submit_login(page) if page_is_login?(page)
|
|
30
|
+
submit_2fa(page) if page_is_2fa?(page)
|
|
31
|
+
|
|
32
|
+
save_cookies
|
|
33
|
+
|
|
34
|
+
logged_in?
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def page_is_login?(page)
|
|
38
|
+
page.uri.to_s == Itch::URL::LOGIN
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def page_is_2fa?(page)
|
|
42
|
+
page.uri.to_s.start_with?(Itch::URL::TOTP_FRAGMENT)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
protected
|
|
46
|
+
|
|
47
|
+
def exclude_inspection
|
|
48
|
+
super + [:@password]
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def save_cookies
|
|
52
|
+
@agent.cookie_jar.save(@cookie_path) if @cookie_path
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def submit_2fa(page)
|
|
56
|
+
form = page.form_with(action: page.uri.to_s)
|
|
57
|
+
form.code = totp_code
|
|
58
|
+
page = form.submit
|
|
59
|
+
|
|
60
|
+
if page_is_2fa?(page)
|
|
61
|
+
# 2fa failed
|
|
62
|
+
errors = page.css(".form_errors li").map(&:text)
|
|
63
|
+
raise AuthError, "#{errors.size} error#{errors.size == 1 ? "" : "s"} prevented 2fa validation", errors
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
page
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def totp_code
|
|
70
|
+
code = @totp.call
|
|
71
|
+
raise AuthError, "TOTP code is required" if code.nil? || code.empty?
|
|
72
|
+
|
|
73
|
+
code.chomp
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def password
|
|
77
|
+
return @password.call if @password.respond_to? :call
|
|
78
|
+
|
|
79
|
+
@password
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def username
|
|
83
|
+
return @username.call if @username.respond_to? :call
|
|
84
|
+
|
|
85
|
+
@username
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def submit_login(page)
|
|
89
|
+
form = page.form_with(action: Itch::URL::LOGIN)
|
|
90
|
+
|
|
91
|
+
form.username = username
|
|
92
|
+
form.password = password
|
|
93
|
+
|
|
94
|
+
page = form.submit
|
|
95
|
+
|
|
96
|
+
if page_is_login?(page)
|
|
97
|
+
# Login failed
|
|
98
|
+
errors = page.css(".form_errors li").map(&:text)
|
|
99
|
+
raise AuthError.new("#{errors.size} error#{errors.size == 1 ? "" : "s"} prevented login", errors: errors)
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
page
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
data/lib/itch/client.rb
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "forwardable"
|
|
4
|
+
require "mechanize"
|
|
5
|
+
|
|
6
|
+
require_relative "auth"
|
|
7
|
+
require_relative "game"
|
|
8
|
+
require_relative "game_map"
|
|
9
|
+
require_relative "purchases"
|
|
10
|
+
require_relative "simple_inspect"
|
|
11
|
+
|
|
12
|
+
module Itch
|
|
13
|
+
# The primary client interface
|
|
14
|
+
#
|
|
15
|
+
# The top level client delegates to child modules for specific app areas
|
|
16
|
+
# like game and purchases pages
|
|
17
|
+
class Client
|
|
18
|
+
extend Forwardable
|
|
19
|
+
include SimpleInspect
|
|
20
|
+
|
|
21
|
+
def_delegators :@auth, :logged_in?, :login, :totp=, :username=, :password=
|
|
22
|
+
|
|
23
|
+
def initialize(username: nil, password: nil, cookie_path: nil)
|
|
24
|
+
@agent = Mechanize.new
|
|
25
|
+
@auth = Auth.new(@agent, username: username, password: password, cookie_path: cookie_path)
|
|
26
|
+
|
|
27
|
+
@agent.cookie_jar.load(cookie_path) if cookie_path && File.readable?(cookie_path)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def game(id = nil, name: nil)
|
|
31
|
+
Game.new(@agent, game_map, id, name: name)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def purchases
|
|
35
|
+
@purchases ||= Purchases.new(@agent)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def game_map
|
|
39
|
+
@game_map ||= GameMap.new(@agent)
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
data/lib/itch/game.rb
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "rewards"
|
|
4
|
+
require_relative "reward"
|
|
5
|
+
require_relative "require_auth"
|
|
6
|
+
require_relative "simple_inspect"
|
|
7
|
+
|
|
8
|
+
module Itch
|
|
9
|
+
# Represents a single game and sub-resources
|
|
10
|
+
class Game
|
|
11
|
+
include RequireAuth
|
|
12
|
+
include SimpleInspect
|
|
13
|
+
|
|
14
|
+
attr_reader :id, :name, :page_url
|
|
15
|
+
|
|
16
|
+
THEME_DATA = /GameEdit\.ThemeEditor\((.*)\),\$\('#game_appearance_editor_widget_/.freeze
|
|
17
|
+
|
|
18
|
+
def initialize(agent, map, id = nil, name: nil)
|
|
19
|
+
raise Error, "Game ID or name is required" if id.nil? && name.nil?
|
|
20
|
+
|
|
21
|
+
@agent = agent
|
|
22
|
+
@map = map
|
|
23
|
+
|
|
24
|
+
load_game_info(id, name)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def theme
|
|
28
|
+
JSON.parse(theme_data)["theme"]
|
|
29
|
+
rescue StandardError
|
|
30
|
+
{}
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def css
|
|
34
|
+
theme["css"]
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def theme=(theme_data)
|
|
38
|
+
@agent.post edit_theme_url, theme_post_data(theme_data)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def css=(css_data)
|
|
42
|
+
new_theme = theme
|
|
43
|
+
new_theme["css"] = css_data
|
|
44
|
+
self.theme = new_theme
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def rewards
|
|
48
|
+
Rewards.new(@agent, @id)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def reward(id)
|
|
52
|
+
rewards.find { |reward| reward.id == id }
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
protected
|
|
56
|
+
|
|
57
|
+
def load_game_info(id, name)
|
|
58
|
+
if id
|
|
59
|
+
data = @map.find!(id)
|
|
60
|
+
elsif name
|
|
61
|
+
data = @map.find_by_name!(name)
|
|
62
|
+
else
|
|
63
|
+
raise Error, "Name or ID is required when initializing Itch::Game"
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
@id = data[:id]
|
|
67
|
+
@page_url = data[:url]
|
|
68
|
+
@name = data[:name]
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# rubocop:disable Metrics/MethodLength, Metrics/AbcSize
|
|
72
|
+
def theme_post_data(new_theme)
|
|
73
|
+
filtered_theme = new_theme.reject do |k, _v|
|
|
74
|
+
%w[background_image background_repeat background_position banner_image banner_position].include?(k)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
post_data = filtered_theme.transform_keys do |k|
|
|
78
|
+
"layout[#{k}]"
|
|
79
|
+
end.merge({ "csrf_token" => theme_csrf_token })
|
|
80
|
+
|
|
81
|
+
if new_theme["background_image"]
|
|
82
|
+
post_data["layout[background_image][image_id]"] = new_theme["background_image"]["id"]
|
|
83
|
+
post_data["layout[background_image][repeat]"] = new_theme["background_repeat"]
|
|
84
|
+
post_data["layout[background_image][position]"] = new_theme["background_position"]
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
if new_theme["banner_image"]
|
|
88
|
+
post_data["layout[banner_image][id]"] = new_theme["banner_image"]["id"]
|
|
89
|
+
post_data["layout[banner_image][position]"] = new_theme["banner_position"]
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
post_data["header_font_family"] ||= "_default"
|
|
93
|
+
|
|
94
|
+
post_data
|
|
95
|
+
end
|
|
96
|
+
# rubocop:enable Metrics/MethodLength, Metrics/AbcSize
|
|
97
|
+
|
|
98
|
+
def theme_csrf_token
|
|
99
|
+
page = with_login { game_page }
|
|
100
|
+
|
|
101
|
+
page.at_css("meta[name='csrf_token']")["value"]
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def theme_data
|
|
105
|
+
page = with_login { game_page }
|
|
106
|
+
|
|
107
|
+
script = page.css("script").find do |node|
|
|
108
|
+
node.text =~ THEME_DATA
|
|
109
|
+
end.text
|
|
110
|
+
|
|
111
|
+
THEME_DATA.match(script)[1]
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def edit_url
|
|
115
|
+
format(Itch::URL::EDIT_GAME, id: @id)
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def edit_theme_url
|
|
119
|
+
"#{@page_url}/edit"
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def form
|
|
123
|
+
edit_page.form_with(action: edit_url)
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def game_page
|
|
127
|
+
@agent.get @page_url
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def edit_page
|
|
131
|
+
with_login do
|
|
132
|
+
@agent.get edit_url
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
end
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "csv"
|
|
4
|
+
|
|
5
|
+
require_relative "simple_inspect"
|
|
6
|
+
require_relative "require_auth"
|
|
7
|
+
|
|
8
|
+
module Itch
|
|
9
|
+
# Map game names to itch ids
|
|
10
|
+
#
|
|
11
|
+
# Could be handled via API, but would require user API keys or oauth
|
|
12
|
+
class GameMap
|
|
13
|
+
include SimpleInspect
|
|
14
|
+
include RequireAuth
|
|
15
|
+
|
|
16
|
+
def initialize(agent)
|
|
17
|
+
@agent = agent
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def map
|
|
21
|
+
@map ||= begin
|
|
22
|
+
page = with_login do
|
|
23
|
+
@agent.get(Itch::URL::DASHBOARD)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
parse_dashboard page
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def find_by_name(name)
|
|
31
|
+
map[name]
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def find_by_name!(name)
|
|
35
|
+
result = find_by_name(name)
|
|
36
|
+
raise Error, "Cannot find game with name #{name}" unless result
|
|
37
|
+
|
|
38
|
+
result
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def find!(id)
|
|
42
|
+
result = find(id)
|
|
43
|
+
raise Error, "Cannot find game with id #{id}" unless result
|
|
44
|
+
|
|
45
|
+
result
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def find(id)
|
|
49
|
+
id = id.to_s
|
|
50
|
+
map.values.find do |value|
|
|
51
|
+
value[:id] == id
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
protected
|
|
56
|
+
|
|
57
|
+
def parse_dashboard(page)
|
|
58
|
+
page.css(".game_row").map do |row|
|
|
59
|
+
title = row.at_css(".game_title .game_link")
|
|
60
|
+
name = title.text
|
|
61
|
+
url = title["href"]
|
|
62
|
+
edit_url = row.at_xpath('.//a[text()="Edit"]/@href').value
|
|
63
|
+
|
|
64
|
+
id = edit_url.match(%r{/game/edit/(\d+)})[1]
|
|
65
|
+
[name, { id: id, url: url, name: name }] if id && name
|
|
66
|
+
end.compact.to_h
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "csv"
|
|
4
|
+
require_relative "require_auth"
|
|
5
|
+
require_relative "simple_inspect"
|
|
6
|
+
require_relative "request"
|
|
7
|
+
|
|
8
|
+
module Itch
|
|
9
|
+
# Return purchase history and history by date
|
|
10
|
+
class Purchases
|
|
11
|
+
include RequireAuth
|
|
12
|
+
include SimpleInspect
|
|
13
|
+
include Request
|
|
14
|
+
|
|
15
|
+
def initialize(agent)
|
|
16
|
+
@agent = agent
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def history_by_month(month, year)
|
|
20
|
+
fetch_csv format(Itch::URL::MONTH_PURCHASES_CSV, month: month, year: year)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def history_by_year(year)
|
|
24
|
+
fetch_csv format(Itch::URL::YEAR_PURCHASES_CSV, year: year)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def history
|
|
28
|
+
fetch_csv Itch::URL::PURCHASES_CSV
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
protected
|
|
32
|
+
|
|
33
|
+
def fetch_csv(url)
|
|
34
|
+
page = with_login do
|
|
35
|
+
@agent.get(url)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
validate_response(page, action: "fetching purchase CSV", content_type: "text/csv")
|
|
39
|
+
|
|
40
|
+
CSV.new(page.content, headers: true)
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
data/lib/itch/request.rb
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Itch
|
|
4
|
+
# Mix in for request validation and response parsing
|
|
5
|
+
module Request
|
|
6
|
+
# rubocop:disable all
|
|
7
|
+
def validate_response(page, action: "making request", content_type: "text/html")
|
|
8
|
+
response_type = page.response["content-type"]
|
|
9
|
+
return if page.code == "200" && response_type == content_type
|
|
10
|
+
|
|
11
|
+
unless response_type == "application/json"
|
|
12
|
+
raise Error, "Unexpected error occurred while #{action}: Response code #{page.code}"
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
error_data = nil
|
|
16
|
+
begin
|
|
17
|
+
data = JSON.parse(page.content)
|
|
18
|
+
if data["errors"]
|
|
19
|
+
error_data = data["errors"].respond_to?(:join) ? "\n#{data["errors"].join("\n")}" : data["errors"]
|
|
20
|
+
end
|
|
21
|
+
rescue StandardError
|
|
22
|
+
raise Error, "Error parsing response while #{action}"
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
raise Error, "Unexpected error occurred while #{action}: #{error_data}"
|
|
26
|
+
end
|
|
27
|
+
# rubocop:enable all
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Itch
|
|
4
|
+
# Mixin to raise exceptions when a request redirects to login page
|
|
5
|
+
module RequireAuth
|
|
6
|
+
def require_auth(page)
|
|
7
|
+
raise AuthError, "User is not logged in" if page.uri.to_s == Itch::URL::LOGIN
|
|
8
|
+
|
|
9
|
+
page
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def with_login
|
|
13
|
+
require_auth yield
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
data/lib/itch/reward.rb
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "csv"
|
|
4
|
+
|
|
5
|
+
require_relative "simple_inspect"
|
|
6
|
+
|
|
7
|
+
module Itch
|
|
8
|
+
# Data container for single reward
|
|
9
|
+
class Reward
|
|
10
|
+
include SimpleInspect
|
|
11
|
+
|
|
12
|
+
attr_accessor :amount, :archived, :claimed, :description, :id, :price, :title
|
|
13
|
+
|
|
14
|
+
# rubocop:disable Metrics/ParameterLists
|
|
15
|
+
def initialize(amount:, description:, price:, title:, archived: false, claimed: 0, id: nil)
|
|
16
|
+
@id = id
|
|
17
|
+
@description = description
|
|
18
|
+
@title = title
|
|
19
|
+
@amount = amount
|
|
20
|
+
@price = price
|
|
21
|
+
@claimed = claimed
|
|
22
|
+
@archived = archived
|
|
23
|
+
end
|
|
24
|
+
# rubocop:enable Metrics/ParameterLists
|
|
25
|
+
end
|
|
26
|
+
end
|
data/lib/itch/rewards.rb
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "csv"
|
|
4
|
+
require "json"
|
|
5
|
+
|
|
6
|
+
require_relative "require_auth"
|
|
7
|
+
require_relative "simple_inspect"
|
|
8
|
+
require_relative "reward"
|
|
9
|
+
require_relative "request"
|
|
10
|
+
|
|
11
|
+
module Itch
|
|
12
|
+
# Fetch rewards and history
|
|
13
|
+
class Rewards
|
|
14
|
+
include SimpleInspect
|
|
15
|
+
include RequireAuth
|
|
16
|
+
include Request
|
|
17
|
+
|
|
18
|
+
REWARD_DATA = /GameEdit\.EditRewards\(.*?(?:"rewards":(\[.*\]),)?"reward_noun":"(.*(?<!\\))"}\)/.freeze
|
|
19
|
+
|
|
20
|
+
def initialize(agent, game_id)
|
|
21
|
+
@agent = agent
|
|
22
|
+
@game_id = game_id
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def history
|
|
26
|
+
page = with_login do
|
|
27
|
+
@agent.get(csv_url)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
validate_response(page, action: "fetching reward CSV", content_type: "text/csv")
|
|
31
|
+
|
|
32
|
+
CSV.new(page.content, headers: true)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def list
|
|
36
|
+
rewards, _noun = fetch_rewards_data
|
|
37
|
+
|
|
38
|
+
return [] unless rewards
|
|
39
|
+
|
|
40
|
+
build_rewards(rewards)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def save(rewards)
|
|
44
|
+
_rewards, noun = fetch_rewards_data
|
|
45
|
+
|
|
46
|
+
post_data = build_post_data(rewards, noun)
|
|
47
|
+
|
|
48
|
+
result = @agent.post rewards_url, post_data
|
|
49
|
+
|
|
50
|
+
validate_response(result, action: "updating rewards")
|
|
51
|
+
|
|
52
|
+
list
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
protected
|
|
56
|
+
|
|
57
|
+
# rubocop:disable Metrics/MethodLength
|
|
58
|
+
def build_post_data(rewards, noun)
|
|
59
|
+
rewards.map.with_index do |reward, index|
|
|
60
|
+
{
|
|
61
|
+
"rewards[#{index}][title]" => reward.title,
|
|
62
|
+
"rewards[#{index}][description]" => reward.description,
|
|
63
|
+
"rewards[#{index}][price]" => reward.price,
|
|
64
|
+
"rewards[#{index}][amount]" => reward.amount
|
|
65
|
+
}.tap do |data|
|
|
66
|
+
data["rewards[#{index}][archived]"] = "on" if reward.archived
|
|
67
|
+
|
|
68
|
+
data["rewards[#{index}][id]"] = reward.id if reward.id
|
|
69
|
+
end
|
|
70
|
+
end.inject({ "reward_noun" => noun }, &:merge)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def build_rewards(data)
|
|
74
|
+
JSON.parse(data).map do |reward|
|
|
75
|
+
Reward.new(
|
|
76
|
+
id: reward["id"],
|
|
77
|
+
description: reward["description"],
|
|
78
|
+
title: reward["title"],
|
|
79
|
+
amount: reward["amount"],
|
|
80
|
+
price: reward["price"],
|
|
81
|
+
claimed: reward["claimed_count"],
|
|
82
|
+
archived: reward["archived"]
|
|
83
|
+
)
|
|
84
|
+
rescue StandardError
|
|
85
|
+
[]
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
# rubocop:enable Metrics/MethodLength
|
|
89
|
+
|
|
90
|
+
def fetch_rewards_data
|
|
91
|
+
page = with_login do
|
|
92
|
+
@agent.get(rewards_url)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
raise Error, "Could not find game id #{@game_id} rewards" unless page.code == "200"
|
|
96
|
+
|
|
97
|
+
script = page.css("script").find do |node|
|
|
98
|
+
node.text =~ REWARD_DATA
|
|
99
|
+
end.text
|
|
100
|
+
|
|
101
|
+
REWARD_DATA.match(script)[1..]
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def parse_row(row)
|
|
105
|
+
id = row.css('input[type="hidden"]').find do |input|
|
|
106
|
+
input.name.match(/^rewards\[(\d+)\]\[id\]/)
|
|
107
|
+
end.value
|
|
108
|
+
|
|
109
|
+
attributes = %w[title description amount price].map do |name|
|
|
110
|
+
[name.to_sym, row.css_at(".reward_#{name}_input").value]
|
|
111
|
+
end.to_h
|
|
112
|
+
attributes[:claimed] = row.css_at(".claimed_count").text
|
|
113
|
+
|
|
114
|
+
Reward.new(@agent, @game_id, id, attributes)
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def rewards_url
|
|
118
|
+
format(Itch::URL::REWARDS, id: @game_id)
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def csv_url
|
|
122
|
+
format(Itch::URL::REWARD_CSV, id: @game_id)
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Limit output of agent variable
|
|
4
|
+
module SimpleInspect
|
|
5
|
+
def inspect
|
|
6
|
+
attrs = pretty_print_instance_variables
|
|
7
|
+
values = [
|
|
8
|
+
"#{self.class}##{object_id}",
|
|
9
|
+
*attrs.map { |k| "#{k}: #{instance_variable_get(k)}" }
|
|
10
|
+
]
|
|
11
|
+
|
|
12
|
+
"<#{values.join(" ")}>"
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def pretty_print_instance_variables
|
|
16
|
+
instance_variables.sort.reject { |i| exclude_inspection.include? i }
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def exclude_inspection
|
|
20
|
+
[:agent]
|
|
21
|
+
end
|
|
22
|
+
end
|
data/lib/itch/version.rb
ADDED
data/lib/itch_client.rb
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "itch/version"
|
|
4
|
+
require_relative "itch/client"
|
|
5
|
+
|
|
6
|
+
# Top level interface class, delegates to Itch::Client
|
|
7
|
+
module Itch
|
|
8
|
+
class Error < StandardError; end
|
|
9
|
+
|
|
10
|
+
# Authentication errors, includes individual error messages
|
|
11
|
+
# in errors key
|
|
12
|
+
class AuthError < Error
|
|
13
|
+
attr_reader :errors
|
|
14
|
+
|
|
15
|
+
def initialize(message, errors: [])
|
|
16
|
+
super(message)
|
|
17
|
+
@errors = errors
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def message
|
|
21
|
+
m = super
|
|
22
|
+
|
|
23
|
+
return "#{m}\n\n#{errors.join("\n")}" if errors.any?
|
|
24
|
+
|
|
25
|
+
m
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
module URL
|
|
30
|
+
DASHBOARD = "https://itch.io/dashboard"
|
|
31
|
+
EDIT_GAME = "https://itch.io/game/edit/%<id>d"
|
|
32
|
+
GAME = "https://%<username>s.itch.io/%<slug>s"
|
|
33
|
+
LOGIN = "https://itch.io/login"
|
|
34
|
+
MONTH_PURCHASES_CSV = "https://itch.io/export-purchases/by-date/%<month>d-%<year>d"
|
|
35
|
+
PURCHASES_CSV = "https://itch.io/export-purchases/all"
|
|
36
|
+
REWARD_CSV = "https://itch.io/game/rewards/%<id>d/claimed?format=csv"
|
|
37
|
+
REWARDS = "https://itch.io/game/rewards/%<id>d"
|
|
38
|
+
TOTP_FRAGMENT = "https://itch.io/totp/verify/"
|
|
39
|
+
YEAR_PURCHASES_CSV = "https://itch.io/export-purchases/by-date/%<year>d"
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def self.new(**kwargs)
|
|
43
|
+
Client.new(**kwargs)
|
|
44
|
+
end
|
|
45
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: itch_client
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.2.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Billiam
|
|
8
|
+
autorequire:
|
|
9
|
+
bindir: bin
|
|
10
|
+
cert_chain: []
|
|
11
|
+
date: 2021-05-27 00:00:00.000000000 Z
|
|
12
|
+
dependencies:
|
|
13
|
+
- !ruby/object:Gem::Dependency
|
|
14
|
+
name: mechanize
|
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
|
16
|
+
requirements:
|
|
17
|
+
- - "~>"
|
|
18
|
+
- !ruby/object:Gem::Version
|
|
19
|
+
version: '2.8'
|
|
20
|
+
type: :runtime
|
|
21
|
+
prerelease: false
|
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
23
|
+
requirements:
|
|
24
|
+
- - "~>"
|
|
25
|
+
- !ruby/object:Gem::Version
|
|
26
|
+
version: '2.8'
|
|
27
|
+
description:
|
|
28
|
+
email:
|
|
29
|
+
- billiamthesecond@gmail.com
|
|
30
|
+
executables: []
|
|
31
|
+
extensions: []
|
|
32
|
+
extra_rdoc_files: []
|
|
33
|
+
files:
|
|
34
|
+
- ".github/workflows/main.yml"
|
|
35
|
+
- ".gitignore"
|
|
36
|
+
- ".rspec"
|
|
37
|
+
- ".rubocop.yml"
|
|
38
|
+
- CHANGELOG.md
|
|
39
|
+
- Gemfile
|
|
40
|
+
- Gemfile.lock
|
|
41
|
+
- LICENSE.txt
|
|
42
|
+
- README.md
|
|
43
|
+
- Rakefile
|
|
44
|
+
- bin/console
|
|
45
|
+
- bin/setup
|
|
46
|
+
- itch_client.gemspec
|
|
47
|
+
- lib/itch/auth.rb
|
|
48
|
+
- lib/itch/client.rb
|
|
49
|
+
- lib/itch/game.rb
|
|
50
|
+
- lib/itch/game_map.rb
|
|
51
|
+
- lib/itch/purchases.rb
|
|
52
|
+
- lib/itch/request.rb
|
|
53
|
+
- lib/itch/require_auth.rb
|
|
54
|
+
- lib/itch/reward.rb
|
|
55
|
+
- lib/itch/rewards.rb
|
|
56
|
+
- lib/itch/simple_inspect.rb
|
|
57
|
+
- lib/itch/version.rb
|
|
58
|
+
- lib/itch_client.rb
|
|
59
|
+
homepage: https://github.com/Billiam/itch-client
|
|
60
|
+
licenses:
|
|
61
|
+
- MIT
|
|
62
|
+
metadata:
|
|
63
|
+
homepage_uri: https://github.com/Billiam/itch-client
|
|
64
|
+
source_code_uri: https://github.com/Billiam/itch-client
|
|
65
|
+
changelog_uri: https://github.com/Billiam/itch-client/CHANGELOG.md
|
|
66
|
+
post_install_message:
|
|
67
|
+
rdoc_options: []
|
|
68
|
+
require_paths:
|
|
69
|
+
- lib
|
|
70
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
71
|
+
requirements:
|
|
72
|
+
- - ">="
|
|
73
|
+
- !ruby/object:Gem::Version
|
|
74
|
+
version: 2.7.0
|
|
75
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
76
|
+
requirements:
|
|
77
|
+
- - ">="
|
|
78
|
+
- !ruby/object:Gem::Version
|
|
79
|
+
version: '0'
|
|
80
|
+
requirements: []
|
|
81
|
+
rubygems_version: 3.2.15
|
|
82
|
+
signing_key:
|
|
83
|
+
specification_version: 4
|
|
84
|
+
summary: Itch.io screen scraping utility
|
|
85
|
+
test_files: []
|