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 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
@@ -0,0 +1,12 @@
1
+ /.bundle/
2
+ /.yardoc
3
+ /_yardoc/
4
+ /coverage/
5
+ /doc/
6
+ /pkg/
7
+ /spec/reports/
8
+ /tmp/
9
+
10
+ # rspec failure tracking
11
+ .rspec_status
12
+ .env
data/.rspec ADDED
@@ -0,0 +1,3 @@
1
+ --format documentation
2
+ --color
3
+ --require spec_helper
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
@@ -0,0 +1,5 @@
1
+ ## [Unreleased]
2
+
3
+ ## [0.1.0] - 2021-05-22
4
+
5
+ - Initial release
data/Gemfile ADDED
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ source "https://rubygems.org"
4
+
5
+ # Specify your gem's dependencies in itch.gemspec
6
+ gemspec
7
+
8
+ gem "rake", "~> 13.0"
9
+
10
+ gem "rspec", "~> 3.0"
11
+
12
+ gem "rubocop", "~> 1.7"
13
+
14
+ gem "irb", "~> 1.3"
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
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "rspec/core/rake_task"
5
+
6
+ RSpec::Core::RakeTask.new(:spec)
7
+
8
+ require "rubocop/rake_task"
9
+
10
+ RuboCop::RakeTask.new
11
+
12
+ task default: %i[spec rubocop]
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
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+ IFS=$'\n\t'
4
+ set -vx
5
+
6
+ bundle install
7
+
8
+ # Do any other automated setup that you need to do here
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Itch
4
+ VERSION = "0.2.0"
5
+ end
@@ -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: []