furaffinity 0.1.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.
data/README.md ADDED
@@ -0,0 +1,84 @@
1
+ # furaffinity
2
+
3
+ A gem to interface with FurAffinity, along with a neat little CLI.
4
+
5
+ ## Installation
6
+
7
+ ```sh
8
+ gem install furaffinity
9
+ ```
10
+
11
+ ## Usage
12
+
13
+ CLI usage is as follows:
14
+
15
+ ```sh
16
+ # replace "cookie-a" and "cookie-b" with the values of the respective cookies
17
+ # use firefox's web inspector for that, the "Storage" tab displays it nicely
18
+ fa auth cookie-a cookie-b
19
+
20
+ # retrieve your notification counters as JSON
21
+ fa notifications
22
+ # {
23
+ # "submissions": 30944,
24
+ # "watches": 317,
25
+ # "comments": 278,
26
+ # "favourites": 1018,
27
+ # "journals": 1642,
28
+ # "trouble_tickets": 9
29
+ # }
30
+
31
+ # upload a new submission
32
+ fa upload my_image.png --title "test post please ignore" --description "This is an image as you can see" --rating general --scrap
33
+ ```
34
+
35
+ There is also a way to upload submissions in bulk: `fa queue`
36
+
37
+ ```sh
38
+ # set your preferred editor in ENV
39
+ export EDITOR=vi
40
+
41
+ # initialise queue directory
42
+ fa queue init my_queue
43
+ cd my_queue
44
+
45
+ # copy files to upload into the queue directory
46
+ cp ~/Pictures/pic*.png .
47
+
48
+ # add files, an editor will open up for each file to fill in the details
49
+ fa queue add pic1.png
50
+ fa queue add pic2.png pic3.png
51
+
52
+ # see the status of the queue
53
+ fa queue status
54
+
55
+ # use your preferred editor to change the submission information afterwards
56
+ vi pic2.png.info.yml
57
+
58
+ # open up an editor to rearrange the queue order
59
+ fa queue reorder
60
+
61
+ # upload the entire queue
62
+ fa queue upload
63
+
64
+ # once everything's uploaded you can remove the already uploaded pics
65
+ fa queue clean
66
+ ```
67
+
68
+ ## Development
69
+
70
+ After checking out the repo, run `bin/setup` to install dependencies.
71
+
72
+ 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).
73
+
74
+ ## Contributing
75
+
76
+ Bug reports and pull requests are welcome on GitHub at https://github.com/nilsding/furaffinity. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the [code of conduct](https://github.com/nilsding/furaffinity/blob/main/CODE_OF_CONDUCT.md).
77
+
78
+ ## License
79
+
80
+ The gem is available as open source under the terms of the [AGPLv3 License](https://opensource.org/license/agpl-v3/).
81
+
82
+ ## Code of Conduct
83
+
84
+ Everyone interacting in the Furaffinity project's codebases, issue trackers, chat rooms and mailing lists is expected to follow the [code of conduct](https://github.com/nilsding/furaffinity/blob/main/CODE_OF_CONDUCT.md).
data/Rakefile ADDED
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+
5
+ task :default
data/exe/fa ADDED
@@ -0,0 +1,11 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ lib_dir = File.expand_path("../lib", __dir__)
4
+ $LOAD_PATH.unshift(lib_dir) unless $LOAD_PATH.include?(lib_dir)
5
+
6
+ require "furaffinity"
7
+
8
+ SemanticLogger.sync!
9
+ SemanticLogger.add_appender(io: $stdout, formatter: :color)
10
+
11
+ Furaffinity::Cli.start
@@ -0,0 +1,126 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "thor"
5
+
6
+ module Furaffinity
7
+ class Cli < Thor
8
+ include CliBase
9
+
10
+ class_option :log_level,
11
+ type: :string,
12
+ desc: "Log level to use",
13
+ default: "info"
14
+
15
+ class_option :config,
16
+ type: :string,
17
+ desc: "Path to the config",
18
+ default: File.join(Dir.home, ".farc")
19
+
20
+ desc "auth A_COOKIE B_COOKIE", "Store authentication info for FurAffinity"
21
+ def auth(a_cookie, b_cookie)
22
+ set_log_level(options)
23
+ config = config_for(options)
24
+ config.set!(a: a_cookie, b: b_cookie)
25
+ say "Authentication info stored.", :green
26
+ end
27
+
28
+ desc "notifications", "Get the current notification counters as JSON"
29
+ def notifications
30
+ set_log_level(options)
31
+ config = config_for(options)
32
+ client = config.new_client
33
+
34
+ puts JSON.pretty_generate client.notifications
35
+ end
36
+
37
+ desc "upload FILE_PATH", "Upload a new submission"
38
+ option :type,
39
+ type: :string,
40
+ desc: "Submission type. One of: #{Furaffinity::Client::SUBMISSION_TYPES.join(", ")}",
41
+ default: "submission"
42
+ option :title,
43
+ type: :string,
44
+ desc: "Submission title.",
45
+ required: true
46
+ option :description,
47
+ type: :string,
48
+ desc: "Submission description.",
49
+ required: true
50
+ option :rating,
51
+ type: :string,
52
+ desc: "Submission rating. One of: #{Furaffinity::Client::RATING_MAP.keys.join(", ")}",
53
+ required: true
54
+ option :lock_comments,
55
+ type: :boolean,
56
+ desc: "Disable comments on this submission.",
57
+ default: false
58
+ option :scrap,
59
+ type: :boolean,
60
+ desc: "Place this upload to your scraps.",
61
+ default: false
62
+ option :keywords,
63
+ type: :string,
64
+ desc: "Keywords, separated by spaces.",
65
+ default: ""
66
+ option :folder_name,
67
+ type: :string,
68
+ desc: "Place this submission into this folder.",
69
+ default: ""
70
+ def upload(file_path)
71
+ set_log_level(options)
72
+ config = config_for(options)
73
+ client = config.new_client
74
+
75
+ upload_options = options.slice(
76
+ *client
77
+ .method(:upload)
78
+ .parameters
79
+ .select { |(type, name)| %i[keyreq key].include?(type) }
80
+ .map(&:last)
81
+ ).transform_keys(&:to_sym)
82
+ url = client.upload(File.new(file_path), **upload_options)
83
+ say "Submission uploaded! #{url}", :green
84
+ end
85
+
86
+ desc "queue SUBCOMMAND ...ARGS", "Manage an upload queue"
87
+ long_desc <<~LONG_DESC, wrap: false
88
+ `#{basename} queue` manages an upload queue.
89
+
90
+ It behaves somewhat like `git`, where you have to initialise a designated
91
+ directory first for it to use as a queue.
92
+
93
+ An example workflow with `#{basename} queue` would look like:
94
+
95
+ # set your preferred editor in ENV
96
+ export EDITOR=vi
97
+
98
+ # initialise queue directory
99
+ #{basename} queue init my_queue
100
+ cd my_queue
101
+
102
+ # copy files to upload into the queue directory
103
+ cp ~/Pictures/pic*.png .
104
+
105
+ # add files, an editor will open up for each file to fill in the details
106
+ #{basename} queue add pic1.png
107
+ #{basename} queue add pic2.png pic3.png
108
+
109
+ # see the status of the queue
110
+ #{basename} queue status
111
+
112
+ # use your preferred editor to change the submission information afterwards
113
+ vi pic2.png.info.yml
114
+
115
+ # open up an editor to rearrange the queue order
116
+ #{basename} queue reorder
117
+
118
+ # upload the entire queue
119
+ #{basename} queue upload
120
+
121
+ # once everything's uploaded you can remove the already uploaded pics
122
+ #{basename} queue clean
123
+ LONG_DESC
124
+ subcommand "queue", CliQueue
125
+ end
126
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Furaffinity
4
+ module CliBase
5
+ def self.included(base)
6
+ def base.exit_on_failure? = true
7
+ end
8
+
9
+ private
10
+
11
+ def set_log_level(options)
12
+ SemanticLogger.default_level = options[:log_level].to_sym
13
+ end
14
+
15
+ def config_for(options) = Config.new(options[:config])
16
+ end
17
+ end
@@ -0,0 +1,140 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "thor"
4
+
5
+ module Furaffinity
6
+ class CliQueue < Thor
7
+ include CliBase
8
+
9
+ desc "init [DIR]", "Initialise a queue directory"
10
+ def init(dir = Dir.pwd)
11
+ result = queue(dir).init
12
+ say "Created new queue dir in #{result.inspect}", :green
13
+ rescue Furaffinity::Error => e
14
+ say e.message, :red
15
+ exit 1
16
+ end
17
+
18
+ desc "add FILE...", "Add a file to the upload queue"
19
+ def add(*files)
20
+ if files.empty?
21
+ say "You need to pass a file.", :red
22
+ help :add
23
+ exit 1
24
+ end
25
+
26
+ queue.reload
27
+
28
+ files_added = queue.add(*files)
29
+ files_added.each do |file|
30
+ say "added #{file.inspect}", :green
31
+ end
32
+ rescue Furaffinity::Error => e
33
+ say e.message, :red
34
+ exit 1
35
+ end
36
+
37
+ desc "remove FILE...", "Removes a file from the upload queue"
38
+ def remove(*files)
39
+ if files.empty?
40
+ say "You need to pass a file.", :red
41
+ help :remove
42
+ exit 1
43
+ end
44
+
45
+ queue.reload
46
+
47
+ files_removed = queue.remove(*files)
48
+ files_removed.each do |file|
49
+ say "removed #{file.inspect}", :green
50
+ end
51
+ rescue Furaffinity::Error => e
52
+ say e.message, :red
53
+ exit 1
54
+ end
55
+
56
+ desc "clean", "Remove uploaded files"
57
+ def clean
58
+ queue.reload
59
+ queue.clean
60
+ say "Removed uploaded files", :green
61
+ rescue Furaffinity::Error => e
62
+ say e.message, :red
63
+ exit 1
64
+ end
65
+
66
+ desc "reorder", "Open an editor to rearrange the queue"
67
+ def reorder
68
+ queue.reload
69
+ queue.reorder unless queue.queue.empty?
70
+
71
+ invoke :status
72
+ end
73
+
74
+ desc "status", "Print the current status of the queue"
75
+ def status
76
+ queue.reload
77
+
78
+ if queue.queue.empty?
79
+ say "Nothing is in the queue yet, use `#{File.basename $0} queue add ...` to add files.", :yellow
80
+
81
+ print_uploaded_files
82
+ return
83
+ end
84
+
85
+ say "Enqueued files:"
86
+ queue_table = [["Position", "File name", "Title", "Rating"], :separator]
87
+ queue.queue.each_with_index do |file_name, idx|
88
+ file_info = queue.file_info.fetch(file_name)
89
+ queue_table << [idx + 1, file_name, file_info[:title], file_info[:rating]]
90
+ end
91
+ print_table queue_table, borders: true
92
+
93
+ print_uploaded_files
94
+
95
+ rescue Furaffinity::Error => e
96
+ say e.message, :red
97
+ exit 1
98
+ end
99
+
100
+ desc "upload", "Upload all submissions in the queue."
101
+ option :wait_time, type: :numeric, desc: "Seconds to wait between each upload.", default: 60
102
+ def upload
103
+ if options[:wait_time] < 30
104
+ say "--wait-time must be at least 30", :red
105
+ exit 1
106
+ end
107
+
108
+ queue.reload
109
+ queue.upload(options[:wait_time])
110
+ say "Submissions uploaded.", :green
111
+ rescue Furaffinity::Error => e
112
+ say e.message, :red
113
+ exit 1
114
+ end
115
+
116
+ private
117
+
118
+ def queue(dir = Dir.pwd)
119
+ @queue ||= begin
120
+ set_log_level(options)
121
+ config = config_for(options)
122
+ Queue.new(config.new_client, dir)
123
+ end
124
+ end
125
+
126
+ def print_uploaded_files
127
+ uploaded_files = queue.uploaded_files
128
+ unless uploaded_files.empty?
129
+ say
130
+ say "Uploaded files (will be removed when you run `#{File.basename $0} queue clean`):"
131
+ uploaded_table = [["File name", "Title", "Submission URL"], :separator]
132
+ uploaded_files.each do |file_name, upload_status|
133
+ file_info = queue.file_info.fetch(file_name)
134
+ uploaded_table << [file_name, file_info[:title], upload_status[:url]]
135
+ end
136
+ print_table uploaded_table, borders: true
137
+ end
138
+ end
139
+ end
140
+ end
@@ -0,0 +1,175 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "httpx"
4
+ require "nokogiri"
5
+
6
+ module Furaffinity
7
+ class Client
8
+ include SemanticLogger::Loggable
9
+
10
+ BASE_URL = "https://www.furaffinity.net"
11
+
12
+ # @param a [String] value of the `a` cookie
13
+ # @param b [String] value of the `b` cookie
14
+ def initialize(a:, b:)
15
+ raise Error.new("a needs to be a non-zero string") unless a.is_a?(String) || a.empty?
16
+ raise Error.new("b needs to be a non-zero string") unless b.is_a?(String) || b.empty?
17
+
18
+ @auth_cookies = { "a" => a, "b" => b }
19
+ end
20
+
21
+ def http_client
22
+ HTTPX
23
+ .plugin(:cookies)
24
+ .plugin(:stream)
25
+ # .plugin(:follow_redirects)
26
+ .with(headers: { "user-agent" => "furaffinity/#{Furaffinity::VERSION} (Ruby)" })
27
+ .with_cookies(@auth_cookies)
28
+ end
29
+
30
+ def get(path, client: nil)
31
+ client ||= http_client
32
+ url = File.join(BASE_URL, path)
33
+ logger.measure_trace("GET #{url}") do
34
+ client.get(url)
35
+ end
36
+ end
37
+
38
+ def post(path, form: {}, client: nil)
39
+ client ||= http_client
40
+ url = File.join(BASE_URL, path)
41
+ logger.measure_trace("POST #{url}", { form: }) do
42
+ client.post(url, form:)
43
+ end
44
+ end
45
+
46
+ NOTIFICATIONS_MAP = {
47
+ "submission" => :submissions,
48
+ "watch" => :watches,
49
+ "comment" => :comments,
50
+ "favorite" => :favourites,
51
+ "journal" => :journals,
52
+ "unread" => :notes,
53
+ "troubleticket" => :trouble_tickets,
54
+ }
55
+
56
+ def notifications
57
+ get("/")
58
+ .then(&method(:parse_response))
59
+ .css("a.notification-container")
60
+ .map { _1.attr(:title) }
61
+ .uniq
62
+ .each_with_object({}) do |item, h|
63
+ count, type, *_rest = item.split(" ")
64
+ count = count.tr(",", "").strip.to_i
65
+ h[NOTIFICATIONS_MAP.fetch(type.downcase.strip)] = count
66
+ end
67
+ end
68
+
69
+ SUBMISSION_TYPES = %i[submission story poetry music].freeze
70
+ RATING_MAP = {
71
+ general: 0,
72
+ mature: 2,
73
+ adult: 1,
74
+ }.freeze
75
+
76
+ def fake_upload(file, title:, rating:, description:, keywords:, folder_name: "", lock_comments: false, scrap: false, type: :submission)
77
+ type = type.to_sym
78
+ raise ArgumentError.new("#{type.inspect} is not in #{SUBMISSION_TYPES.inspect}") unless SUBMISSION_TYPES.include?(type)
79
+ rating = rating.to_sym
80
+ raise ArgumentError.new("#{rating.inspect} is not in #{RATING_MAP.keys.inspect}") unless RATING_MAP.include?(rating)
81
+ raise "not a file" unless file.is_a?(File)
82
+ params = { MAX_FILE_SIZE: "10485760" }
83
+ raise ArgumentError.new("file size of #{file.size} is greater than FA limit of #{params[:MAX_FILE_SIZE]}") if file.size > params[:MAX_FILE_SIZE].to_i
84
+ "https://..."
85
+ end
86
+
87
+ # @param file [File]
88
+ def upload(file, title:, rating:, description:, keywords:, folder_name: "", lock_comments: false, scrap: false, type: :submission)
89
+ type = type.to_sym
90
+ raise ArgumentError.new("#{type.inspect} is not in #{SUBMISSION_TYPES.inspect}") unless SUBMISSION_TYPES.include?(type)
91
+ rating = rating.to_sym
92
+ raise ArgumentError.new("#{rating.inspect} is not in #{RATING_MAP.keys.inspect}") unless RATING_MAP.include?(rating)
93
+
94
+ client = http_client
95
+
96
+ # step 1: get the required keys
97
+ logger.trace "Extracting keys from upload form"
98
+ response = get("/submit/", client:).then(&method(:parse_response))
99
+ params = {
100
+ submission_type: type,
101
+ submission: file,
102
+ thumbnail: {
103
+ content_type: "application/octet-stream",
104
+ filename: "",
105
+ body: ""
106
+ },
107
+ }
108
+ params.merge!(
109
+ %w[MAX_FILE_SIZE key]
110
+ .map { [_1.to_sym, response.css("form#myform input[name=#{_1}]").first.attr(:value)] }
111
+ .to_h
112
+ )
113
+ raise ArgumentError.new("file size of #{file.size} is greater than FA limit of #{params[:MAX_FILE_SIZE]}") if file.size > params[:MAX_FILE_SIZE].to_i
114
+
115
+ # step 2: upload the submission file
116
+ logger.debug "Uploading submission..."
117
+ upload_response = post("/submit/upload/", form: params, client:)
118
+ # for some reason HTTPX performs a GET redirect with the params so we
119
+ # can't use its plugin here
120
+ # --> follow the redirect ourselves
121
+ raise Error.new("expected a 302 response, got #{upload_response.status}") unless upload_response.status == 302
122
+
123
+ redirect_location = upload_response.headers[:location]
124
+ unless redirect_location == "/submit/finalize/"
125
+ logger.warn "unexpected redirect target #{redirect_location.inspect}, expected \"/submit/finalize/\". continuing regardless ..."
126
+ end
127
+ response = get(redirect_location, client:).then(&method(:parse_response))
128
+
129
+ params = {
130
+ key: response.css("form#myform input[name=key]").first.attr(:value),
131
+
132
+ # category, "1" is "Visual Art -> All"
133
+ cat: "1",
134
+ # theme, "1" is "General Things -> All"
135
+ atype: "1",
136
+ # species, "1" is "Unspecified / Any"
137
+ species: "1",
138
+ # gender, "0" is "Any"
139
+ gender: "0",
140
+
141
+ rating: RATING_MAP.fetch(rating),
142
+ title: title,
143
+ message: description,
144
+ keywords: keywords,
145
+
146
+ create_folder_name: folder_name,
147
+
148
+ # finalize button :)
149
+ finalize: "Finalize ",
150
+ }
151
+ params[:lock_comments] = "1" if lock_comments
152
+ params[:scrap] = "1" if scrap
153
+
154
+ logger.debug "Finalising submission..."
155
+ finalize_response = post("/submit/finalize/", form: params, client:)
156
+ if finalize_response.status == 302
157
+ redirect_location = finalize_response.headers[:location]
158
+ url = File.join(BASE_URL, redirect_location)
159
+ logger.info "Uploaded! #{url}"
160
+ return url
161
+ else
162
+ fa_error = parse_response(finalize_response).css(".redirect-message").text
163
+ raise Error.new("FA returned: #{fa_error}")
164
+ end
165
+ end
166
+
167
+ private
168
+
169
+ def parse_response(httpx_response)
170
+ logger.measure_trace "Parsing response" do
171
+ Nokogiri::HTML.parse(httpx_response)
172
+ end
173
+ end
174
+ end
175
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+
5
+ module Furaffinity
6
+ class Config
7
+ include SemanticLogger::Loggable
8
+
9
+ def initialize(path)
10
+ @path = path
11
+ if File.exist?(path)
12
+ logger.measure_trace("Loading configuration") do
13
+ @config_hash = YAML.safe_load_file(path)
14
+ end
15
+ else
16
+ @config_hash = {}
17
+ end
18
+ rescue => e
19
+ logger.fatal("Error while loading configuration:", e)
20
+ raise
21
+ end
22
+
23
+ # @return [Furaffinity::Client]
24
+ def new_client = Furaffinity::Client.new(a: self[:a], b: self[:b])
25
+
26
+ def [](key) = @config_hash[key.to_s]
27
+ alias get []
28
+
29
+ def []=(key, value)
30
+ @config_hash[key.to_s] = value
31
+ end
32
+
33
+ def set(**kwargs)
34
+ kwargs.each do |k, v|
35
+ self[k] = v
36
+ end
37
+ end
38
+
39
+ def set!(**kwargs)
40
+ set(**kwargs)
41
+ save
42
+ end
43
+
44
+ def save
45
+ logger.measure_debug("Saving configuration") do
46
+ yaml = @config_hash.to_yaml
47
+ File.open(@path, "w") do |f|
48
+ f.puts yaml
49
+ end
50
+ end
51
+ end
52
+ end
53
+ end