Files
redux-scraper/app/models/domain/fa/user.rb
2024-12-17 17:57:17 +00:00

274 lines
7.3 KiB
Ruby

class Domain::Fa::User < ReduxApplicationRecord
self.table_name = "domain_fa_users"
has_lite_trail(schema_version: 1)
has_many :posts,
class_name: "::Domain::Fa::Post",
inverse_of: :creator,
foreign_key: :creator_id
has_one :disco,
class_name: "::Domain::Fa::UserFactor",
inverse_of: :user,
foreign_key: :user_id,
dependent: :destroy
has_one :avatar,
class_name: "::Domain::Fa::UserAvatar",
inverse_of: :user,
dependent: :destroy
enum :state,
[
:ok, # so far so good, user may not yet be scanned
:scan_error # user has been removed or otherwise, see state_detail
]
# Who this user follows (join table)
has_many :follower_joins,
class_name: "::Domain::Fa::Follow",
foreign_key: :follower_id,
inverse_of: :follower,
dependent: :destroy
# Who this user follows (User model)
has_many :follows, through: :follower_joins, source: :followed
# Who follows this user (join table)
has_many :followed_joins,
class_name: "::Domain::Fa::Follow",
foreign_key: :followed_id,
inverse_of: :followed,
dependent: :destroy
# Who follows this user (User model)
has_many :followed_by, through: :followed_joins, source: :follower
has_many :fav_post_joins, class_name: "::Domain::Fa::Fav", inverse_of: :user
has_many :fav_posts,
class_name: "::Domain::Fa::Post",
through: :fav_post_joins,
source: :post
# FA `name` can be up to 30 chars long,
# `url_name` can be longer.
validates_presence_of(:name, :url_name)
validate do
if name && url_name
expected = self.class.name_to_url_name(name)
matches =
if name.length >= 30
url_name.starts_with?(expected)
else
url_name == expected
end
unless matches
errors.add(
:name,
"name '#{name}' does not match url_name, expected #{expected} but was #{url_name}"
)
end
end
if url_name && url_name =~ /[A-Z]/
errors.add(:url_name, "url_name '#{url_name}' contains uppercase")
end
if url_name && url_name =~ /\s/
errors.add(:url_name, "url_name '#{url_name}' contains whitespace")
end
end
after_initialize do
self.state ||= :ok
self.state_detail ||= {}
self.log_entry_detail ||= {}
end
before_destroy { throw :abort if posts.any? }
SCAN_TYPES = {
page: 1.month,
gallery: 1.year,
follows: 1.month,
favs: 1.month,
incremental: 1.month
}
SCAN_FIELD_TYPES = {
page: :column,
gallery: :column,
follows: :column,
favs: :column,
incremental: :state_detail
}
SCAN_TYPES.keys.each do |scan_type|
define_method(:"due_for_#{scan_type}_scan?") { scan_due?(scan_type) }
define_method(:"time_ago_for_#{scan_type}_scan") do
scanned_ago_in_words(scan_type)
end
next unless SCAN_FIELD_TYPES[scan_type] == :state_detail
define_method(:"scanned_#{scan_type}_at") do
get_scanned_at_value(scan_type)
end
define_method(:"scanned_#{scan_type}_at=") do |value|
set_scanned_at_value(scan_type, value)
end
end
DATE_HELPER = Class.new.extend(ActionView::Helpers::DateHelper)
def scanned_ago_in_words(scan_type)
if (timestamp = get_scanned_at_value(scan_type))
DATE_HELPER.time_ago_in_words(timestamp) + " ago"
else
"never"
end
end
def scan_due?(scan_type)
duration =
SCAN_TYPES[scan_type] || raise("invalid scan type '#{scan_type}'")
timestamp = get_scanned_at_value(scan_type)
timestamp.nil? || timestamp <= duration.ago
end
def take_posts_from(other_user)
return if other_user == self
other_posts = other_user.posts
other_posts.update_all(creator_id: id)
other_user.posts.reload
posts.reload
end
def avatar_or_create
self.class.transaction { avatar || create_avatar! }
end
def self.find_or_build_from_submission_parser(submission_parser)
unless submission_parser.is_a?(
Domain::Fa::Parser::ListedSubmissionParserHelper
) ||
submission_parser.is_a?(Domain::Fa::Parser::SubmissionParserHelper)
raise ArgumentError
end
find_or_initialize_by(url_name: submission_parser.artist_url_name) do |user|
user.name = submission_parser.artist
end
end
URL_NAME_EXCEPTIONS = { "Kammiu" => "rammiu" }
def self.name_to_url_name(name)
name = name.strip
URL_NAME_EXCEPTIONS[name] || name.delete("_").gsub(/\s/, "").downcase
end
# TODO: - write method for getting suggested users to follow
# based on this user
# something like:
# UserFactor.nearest_neighbors(
# :for_followed,
# self.disco.for_follows,
# # should this be euclidean? idk, need to test
# distance: "inner_product"
# )
# exclude self.follows.pluck(:followed_id)
# find users similar to 'self' based on who 'self' follows
def similar_users_by_follower(exclude_followed_by: nil)
similar_users_by(:for_follower, exclude_followed_by)
end
# find users similar to 'self one based on who follows 'self'
def similar_users_by_followed(exclude_followed_by: nil)
similar_users_by(:for_followed, exclude_followed_by)
end
def guess_user_page_log_entry
for_path =
proc do |uri_path|
HttpLogEntry
.where(
uri_scheme: "https",
uri_host: "www.furaffinity.net",
uri_path: uri_path
)
.order(created_at: :desc)
.first
end
for_hle_id = proc { |hle_id| hle_id && HttpLogEntry.find_by(id: hle_id) }
# older versions don't end in a trailing slash
hle_id = self.log_entry_detail && self.log_entry_detail["last_user_page_id"]
# first try the last scanned user page (present on most fa user models)
for_hle_id.call(hle_id) ||
# if that's missing, see if there's an existing request logged to the user page
for_path.call("/user/#{url_name}/") ||
# and try the non-trailing-slash version as well
for_path.call("/user/#{url_name}")
# TODO: - maybe can look for posts as well, those might list an avatar
end
def to_param
url_name
end
private
def similar_users_by(factor_col, exclude_followed_by)
query = disco.nearest_neighbors(factor_col, distance: "euclidean")
query =
query.where.not(
user_id: exclude_followed_by.follows.select(:followed_id)
) if exclude_followed_by
users_from_disco_query(query)
end
def users_from_disco_query(disco_query)
Domain::Fa::User
.select("domain_fa_users.*", disco_query.select_values.last)
.joins(:disco)
.merge(disco_query.reselect(:user_id))
end
def get_scanned_at_value(scan_type)
case SCAN_FIELD_TYPES[scan_type]
when :column
send(:"scanned_#{scan_type}_at")
when :state_detail
str = state_detail["scanned_#{scan_type}_at"]
Time.parse(str) if str
else
raise("invalid scan type '#{scan_type}'")
end
end
def set_scanned_at_value(scan_type, value)
case SCAN_FIELD_TYPES[scan_type]
when :column
send(:"scanned_#{scan_type}_at=", value)
when :state_detail
state_detail["scanned_#{scan_type}_at"] = value.iso8601
else
raise("invalid scan type '#{scan_type}'")
end
end
end