0
0
Fork 0
mirror of https://github.com/discourse/discourse.git synced 2026-08-11 02:59:07 +08:00
discourse/plugins/discourse-solved/lib/discourse_solved/schema_utils.rb
David Battersby 524e44794c
FIX: prevent empty QA Page schema in Solved (#40471)
Fixes a gap where the crawler's `qa_page_schema?` gating emits an empty
QAPage on topics whose only replies had no real text.

The QAPage/answer schema in Discourse Solved now consistently excludes
textless posts (onebox/image/emoji-only replies) across all paths by
routing every eligibility check through a single shared
`SchemaUtils.eligible_answer?`
2026-06-02 22:43:49 +08:00

94 lines
3.8 KiB
Ruby
Vendored

# frozen_string_literal: true
module DiscourseSolved
module SchemaUtils
# Generates Schema.org microdata attributes for the crawler view of solved topics.
#
# Structure: QAPage > Question (mainEntity) > acceptedAnswer / suggestedAnswer
#
# - First post gets no schema (its content bubbles up to the Question scope)
# - Ineligible replies (small actions, hidden, or textless onebox/image-only posts)
# get no schema attributes; the crawler view suppresses their microdata so nothing
# attaches to the surrounding Question scope
# - The solved post is marked as acceptedAnswer, other replies as suggestedAnswer
#
# Spec: https://schema.org/QAPage
# Validator: https://validator.schema.org/
def self.schema_markup_enabled?(topic)
return false unless Guardian.new.allow_accepted_answers?(topic)
case SiteSetting.solved_add_schema_markup
when "never"
false
when "answered only"
accepted_answer_visible?(topic)
else
true
end
end
def self.qa_page_schema?(topic)
if topic.instance_variable_defined?(:@qa_page_schema)
return topic.instance_variable_get(:@qa_page_schema)
end
topic.instance_variable_set(
:@qa_page_schema,
schema_markup_enabled?(topic) &&
eligible_answers(topic).any? { |post| eligible_answer?(post) },
)
end
def self.container_schema(topic)
return nil unless qa_page_schema?(topic)
{ itemscope: true, itemtype: "https://schema.org/QAPage" }
end
def self.main_entity_schema(topic)
return nil unless qa_page_schema?(topic)
{ itemprop: "mainEntity", itemscope: true, itemtype: "https://schema.org/Question" }
end
def self.post_schema(post, topic)
return nil unless qa_page_schema?(topic)
return { data: { qa_question: true } } if post.is_first_post?
return {} unless eligible_answer?(post)
if accepted_answer_visible?(topic) && topic.topic_answers.exists?(answer_post_id: post.id)
{ itemprop: "acceptedAnswer", itemscope: true, itemtype: "https://schema.org/Answer" }
else
{ itemprop: "suggestedAnswer", itemscope: true, itemtype: "https://schema.org/Answer" }
end
end
def self.post_answer_meta(post, topic)
return unless post_schema(post, topic)&.[](:itemprop)
"<meta itemprop='upvoteCount' content='#{post.like_count}'>" \
"<meta itemprop='url' content='#{post.full_url}'>"
end
def self.main_entity_meta(topic, crawler_posts)
return unless qa_page_schema?(topic)
first_post = topic.first_post
"<meta itemprop='answerCount' content='#{Array(crawler_posts).count { |p| eligible_answer?(p) }}'>" \
"<meta itemprop='datePublished' content='#{topic.created_at.iso8601}'>" \
"<meta itemprop='name' content='#{ERB::Util.html_escape(topic.title)}'>" \
"<meta itemprop='upvoteCount' content='#{first_post&.like_count || 0}'>"
end
# Whether a post should be treated as an answer in schema output: a visible, regular,
# non-first reply with real text content (excludes onebox/image/emoji-only posts).
# Shared by the crawler microdata path and the JSON-LD service so both agree on what
# counts as an answer.
def self.eligible_answer?(post)
!post.is_first_post? && post.post_type == Post.types[:regular] && !post.hidden &&
post.cooked.present? && Nokogiri::HTML5.fragment(post.cooked).text.strip.present?
end
private_class_method def self.accepted_answer_visible?(topic)
topic.solved&.answer_posts&.any? { |post| Guardian.new.can_see_post?(post) }
end
private_class_method def self.eligible_answers(topic)
topic.posts.where.not(post_number: 1).where(post_type: Post.types[:regular], hidden: false)
end
end
end