198 lines
6.9 KiB
Ruby
198 lines
6.9 KiB
Ruby
module Feeds
|
|
# Responsible for fetching RSS feeds for multiple users.
|
|
#
|
|
# @see Feeds::Import.call
|
|
class Import
|
|
# Fetch the feeds for the given users (with some filtering based on internal business logic).
|
|
#
|
|
# @param users_scope [ActiveRecord::Relation<User>] the initial scope for determining the users
|
|
# whose feeds we'll be fetching.
|
|
#
|
|
# @param earlier_than [NilClass, ActiveSupport::TimeWithZone] when given, use this to further
|
|
# filter the user's who's articles we'll fetch. That is to say, we won't fetch anyone's
|
|
# feeds who's last fetch time was after our earlier_than parameter.
|
|
#
|
|
# @return [Integer] count of total articles fetched.
|
|
def self.call(users_scope: User, earlier_than: nil)
|
|
new(users_scope: users_scope, earlier_than: earlier_than).call
|
|
end
|
|
|
|
def initialize(users_scope: User, earlier_than: nil)
|
|
@earlier_than = earlier_than
|
|
@users = filter_users_from(users_scope: users_scope, earlier_than: earlier_than)
|
|
|
|
# NOTE: should these be configurable? Currently they are the result of empiric
|
|
# tests trying to find a balance between memory occupation and speed
|
|
@users_batch_size = 50
|
|
@num_fetchers = 8
|
|
@num_parsers = 4
|
|
end
|
|
|
|
def call
|
|
total_articles_count = 0
|
|
|
|
users.in_batches(of: users_batch_size) do |batch_of_users|
|
|
feeds_per_user_id = fetch_feeds(batch_of_users)
|
|
|
|
feedjira_objects = parse_feeds(feeds_per_user_id)
|
|
|
|
# NOTE: doing this sequentially to avoid locking problems with the DB
|
|
# and unnecessary conflicts
|
|
articles = feedjira_objects.flat_map do |user_id, feed|
|
|
# TODO: replace `feed` with `feed.url` as `Feeds::AssembleArticleMarkdown`
|
|
# only actually uses feed.url
|
|
user = batch_of_users.detect { |u| u.id == user_id }
|
|
|
|
create_articles_from_user_feed(user, feed)
|
|
end
|
|
|
|
total_articles_count += articles.length
|
|
|
|
# we use `feed_fetched_at` to mark the last time a particular user's feed has been fetched, parsed and imported
|
|
batch_of_users.update_all(feed_fetched_at: Time.current)
|
|
end
|
|
|
|
total_articles_count
|
|
end
|
|
|
|
private
|
|
|
|
attr_reader :earlier_than, :users_batch_size, :num_fetchers, :num_parsers, :users
|
|
|
|
# @return [ActiveRecord::Relation<User>] you'll likely want to set @users from this, but
|
|
# [@jeremyf]'s choosing not to do that as it makes the implementation just a bit
|
|
# cleaner.
|
|
def filter_users_from(users_scope:, earlier_than:)
|
|
users_scope = ArticlePolicy.scope_users_authorized_to_action(users_scope: users_scope, action: :create)
|
|
users_scope = users_scope.where(id: Users::Setting.with_feed.select(:user_id))
|
|
|
|
return users_scope unless earlier_than
|
|
|
|
# Filtering users whose feed hasn't been processed in the last `earlier_than` time span.
|
|
# New users + any user whose feed was processed earlier than the given time
|
|
users_scope.where(feed_fetched_at: nil).or(users_scope.where(feed_fetched_at: ..earlier_than))
|
|
end
|
|
|
|
# TODO: put this in separate service object
|
|
def fetch_feeds(batch_of_users)
|
|
data = batch_of_users.joins(:setting).pluck(:id, "users_settings.feed_url")
|
|
|
|
result = Parallel.map(data, in_threads: num_fetchers) do |user_id, url|
|
|
cleaned_url = url.to_s.strip
|
|
next if cleaned_url.blank?
|
|
|
|
response = HTTParty.get(cleaned_url,
|
|
timeout: 10,
|
|
headers: { "User-Agent" => "Forem Feeds Importer" })
|
|
|
|
[user_id, response.body]
|
|
rescue StandardError => e
|
|
# TODO: add better exception handling
|
|
# For example, we should stop pulling feeds that return 404 and disable them?
|
|
|
|
report_error(
|
|
e,
|
|
feeds_import_info: {
|
|
user_id: user_id,
|
|
url: url,
|
|
error: "Feeds::Import::FetchFeedError"
|
|
},
|
|
)
|
|
|
|
next
|
|
end
|
|
|
|
result.compact.to_h
|
|
end
|
|
|
|
# TODO: put this in separate service object
|
|
def parse_feeds(feeds_per_user_id)
|
|
result = Parallel.map(feeds_per_user_id, in_threads: num_parsers) do |user_id, feed_xml|
|
|
parsed_feed = Feedjira.parse(feed_xml)
|
|
|
|
[user_id, parsed_feed]
|
|
rescue StandardError => e
|
|
# TODO: add better exception handling (eg. rescuing Feedjira::NoParserAvailable separately)
|
|
report_error(
|
|
e,
|
|
feeds_import_info: {
|
|
user_id: user_id,
|
|
error: "Feeds::Import::ParseFeedError"
|
|
},
|
|
)
|
|
|
|
next
|
|
end
|
|
|
|
result.compact.to_h
|
|
end
|
|
|
|
# TODO: currently this is exactly as it was in the RssReader, but we might find
|
|
# avenues for optimization, like:
|
|
# 1. why are we sending N exists query to the DB, one per each item, can we fetch them all?
|
|
# 2. should we queue a batch of workers to create articles, but then, following issues ensue:
|
|
# => synchronization on write (table/row locking)
|
|
# => what happens if 2 jobs are in the queue for the same article?
|
|
# => what happens if they stay in the queue for long and the next iteration of the feeds importer starts?
|
|
def create_articles_from_user_feed(user, feed)
|
|
articles = []
|
|
|
|
feed.entries.reverse_each do |item|
|
|
next if Feeds::CheckItemMediumReply.call(item) || Feeds::CheckItemPreviouslyImported.call(item, user)
|
|
|
|
feed_source_url = item.url.strip.split("?source=")[0]
|
|
article = Article.create!(
|
|
feed_source_url: feed_source_url,
|
|
user_id: user.id,
|
|
published_from_feed: true,
|
|
show_comments: true,
|
|
body_markdown: Feeds::AssembleArticleMarkdown.call(item, user, feed, feed_source_url),
|
|
organization_id: nil,
|
|
)
|
|
|
|
subscribe_author_to_comments(user, article)
|
|
articles.append(article)
|
|
rescue StandardError => e
|
|
# TODO: add better exception handling
|
|
report_error(
|
|
e,
|
|
feeds_import_info: {
|
|
username: user.username,
|
|
feed_url: user.setting&.feed_url,
|
|
item_count: item_count_error(feed),
|
|
error: "Feeds::Import::CreateArticleError:#{item.url}"
|
|
},
|
|
)
|
|
|
|
next
|
|
end
|
|
|
|
if articles.length.positive?
|
|
Slack::WorkflowWebhookWorker.perform_async("Imported #{articles.length} articles for #{user.username}")
|
|
end
|
|
|
|
articles
|
|
end
|
|
|
|
def report_error(error, metadata)
|
|
Rails.logger.error(
|
|
"feeds::import::error::#{error.class}::#{metadata.merge(error_message: error.message)}",
|
|
)
|
|
end
|
|
|
|
def item_count_error(feed)
|
|
return "NIL FEED, INVALID URL" unless feed
|
|
|
|
feed.entries ? feed.entries.length : "no count"
|
|
end
|
|
|
|
def subscribe_author_to_comments(user, article)
|
|
NotificationSubscription.create!(
|
|
user: user,
|
|
notifiable_id: article.id,
|
|
notifiable_type: "Article",
|
|
config: "all_comments",
|
|
)
|
|
end
|
|
end
|
|
end
|