itch_client 0.2.0

Sign up to get free protection for your applications and to get access to all the features.
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: []