common/app/model/content.scala (916 lines of code) (raw):
package model
import java.net.URL
import com.gu.contentapi.client.model.{v1 => contentapi}
import com.gu.contentapi.client.model.schemaorg.SchemaOrg
import com.gu.facia.api.{utils => fapiutils}
import com.gu.facia.client.models.TrailMetaData
import com.gu.targeting.client.Campaign
import common._
import conf.Configuration
import conf.switches.Switches._
import conf.cricketPa.CricketTeams
import layout.ContentWidths.GalleryMedia
import model.content.{Atoms, MediaAssetPlatform, MediaAtom, QuizAtom}
import model.pressed._
import org.jsoup.{Jsoup, nodes}
import org.jsoup.safety.Safelist
import com.github.nscala_time.time.Imports._
import play.api.libs.json._
import views.support._
import scala.jdk.CollectionConverters._
import scala.util.Try
import implicits.Booleans._
import org.joda.time.DateTime
import conf.switches.Switches.InteractiveHeaderSwitch
import _root_.contentapi.SectionTagLookUp.sectionId
sealed trait ContentType {
def content: Content
final def tags: Tags = content.tags
final def elements: Elements = content.elements
final def fields: Fields = content.fields
final def atoms: Option[Atoms] = content.atoms
final def trail: Trail = content.trail
final def metadata: MetaData = content.metadata
final def commercial: Commercial = content.commercial
final def sharelinks: ShareLinks = content.sharelinks
}
final case class GenericContent(override val content: Content) extends ContentType
final case class Content(
trail: Trail,
metadata: MetaData,
tags: Tags,
commercial: Commercial,
elements: Elements,
fields: Fields,
sharelinks: ShareLinks,
atoms: Option[Atoms],
publication: String,
internalPageCode: String,
contributorBio: Option[String],
starRating: Option[Int],
allowUserGeneratedContent: Boolean,
isExpired: Boolean,
productionOffice: Option[String],
tweets: Seq[Tweet],
showInRelated: Boolean,
cardStyle: CardStyle,
shouldHideAdverts: Boolean,
witnessAssignment: Option[String],
isbn: Option[String],
imdb: Option[String],
paFootballTeams: Seq[String],
javascriptReferences: Seq[JsObject],
wordCount: Int,
showByline: Boolean,
rawOpenGraphImage: Option[ImageAsset],
schemaOrg: Option[SchemaOrg],
) {
lazy val isBlog: Boolean = tags.blogs.nonEmpty
lazy val isSeries: Boolean = tags.series.nonEmpty
lazy val isFromTheObserver: Boolean = publication == "The Observer"
lazy val primaryKeyWordTag: Option[Tag] = tags.tags.find(!_.isSectionTag)
lazy val keywordTags: Seq[Tag] = tags.keywords.filter(tag => !tag.isSectionTag)
lazy val shortUrlId = fields.shortUrlId
lazy val shortUrlPath = shortUrlId
lazy val discussionId = Some(shortUrlId)
lazy val isGallery = metadata.contentType.contains(DotcomContentType.Gallery)
lazy val isPhotoEssay = fields.displayHint.contains("photoEssay")
lazy val isColumn = fields.displayHint.contains("column")
lazy val isNumberedList = fields.displayHint.contains("numberedList")
lazy val isSplash = fields.displayHint.contains("column") || fields.displayHint.contains("numberedList")
lazy val isImmersive =
fields.displayHint.contains("immersive") || isGallery || tags.isTheMinuteArticle || isPhotoEssay
lazy val isPaidContent: Boolean = tags.tags.exists { tag => tag.id == "tone/advertisement-features" }
lazy val isTheFilter: Boolean = tags.tags.exists { tag => tag.id == "thefilter/series/the-filter" }
lazy val campaigns: List[Campaign] =
_root_.commercial.targeting.CampaignAgent.getCampaignsForTags(tags.tags.map(_.id))
lazy val shouldAmplify: Boolean = {
val shouldAmplifyContent = {
if (tags.isLiveBlog) {
AmpLiveBlogSwitch.isSwitchedOn
} else if (tags.isArticle) {
val hasBodyBlocks: Boolean = fields.blocks.exists(b => b.body.nonEmpty)
// Some Labs pages have quiz atoms but are not tagged as quizzes
val hasQuizAtoms: Boolean = atoms.exists(a => a.quizzes.nonEmpty)
AmpArticleSwitch.isSwitchedOn && hasBodyBlocks && !tags.isQuiz && !hasQuizAtoms && !isTheFilter
} else {
false
}
}
val containsFormStacks: Boolean = fields.body.contains("guardiannewsandmedia.formstack.com")
shouldAmplifyContent && !containsFormStacks
}
lazy val hasSingleContributor: Boolean = {
(tags.contributors.headOption, trail.byline) match {
case (Some(t), Some(b)) => tags.contributors.length == 1 && t.name == b
case _ => false
}
}
lazy val hasTonalHeaderByline: Boolean = {
(cardStyle == Comment || cardStyle == Editorial || (cardStyle == SpecialReport && tags.isComment)) &&
hasSingleContributor &&
!metadata.contentType.contains(DotcomContentType.ImageContent)
}
lazy val hasBeenModified: Boolean =
new Duration(fields.firstPublicationDate.getOrElse(trail.webPublicationDate), fields.lastModified)
.isLongerThan(Duration.standardSeconds(60))
lazy val hasTonalHeaderIllustration: Boolean = tags.isLetters
lazy val showCircularBylinePicAtSide: Boolean =
!tags.isInteractive &&
(cardStyle == Feature || tags.isReview && tags.hasLargeContributorImage && tags.contributors.length == 1)
lazy val openGraphImageOrFallbackUrl: String =
rawOpenGraphImage
.flatMap(_.url)
.getOrElse(Configuration.images.fallbackLogo)
private val tagsWithoutAgeWarning = Seq(
"tone/help",
"info/info",
"tone/recipes",
"lifeandstyle/series/sudoku",
"type/crossword",
"lifeandstyle/series/kakuro",
"the-scott-trust/the-scott-trust",
"type/signup",
"info/newsletter-sign-up",
"guardian-live-events/guardian-live-events",
"gnm-archive/gnm-archive",
)
def shareImageCategory: ShareImageCategory = {
val isOldNews = !tags.tags.exists(tag => tagsWithoutAgeWarning.contains(tag.id)) &&
trail.webPublicationDate.isBefore(DateTime.now().minusYears(1))
val isOldOpinion = tags.tags.exists(_.id == "tone/comment") &&
trail.webPublicationDate.isBefore(DateTime.now().minusYears(1))
() match {
case paid if isPaidContent => Paid
case oldcommentObserver if isOldOpinion && isFromTheObserver =>
CommentObserverOldContent(trail.webPublicationDate.getYear)
case oldComment if isOldOpinion => CommentGuardianOldContent(trail.webPublicationDate.getYear)
case commentObserver if tags.isComment && isFromTheObserver => ObserverOpinion
case comment if tags.isComment => GuardianOpinion
case live if tags.isLiveBlog => Live
case oldObserver if isOldNews && isFromTheObserver => ObserverOldContent(trail.webPublicationDate.getYear)
case old if isOldNews => GuardianOldContent(trail.webPublicationDate.getYear)
case ratingObserver if starRating.isDefined && isFromTheObserver => ObserverStarRating(starRating.get)
case rating if starRating.isDefined => GuardianStarRating(starRating.get)
case observerDefault if isFromTheObserver => ObserverDefault
case default => GuardianDefault
}
}
// read this before modifying: https://developers.facebook.com/docs/opengraph/howtos/maximizing-distribution-media-content#images
lazy val openGraphImageProfile: ElementProfile = {
val category = shareImageCategory
OpenGraphImage.forCategory(
category,
shouldIncludeOverlay = FacebookShareImageLogoOverlay.isSwitchedOn,
shouldUpscale = true,
)
}
lazy val openGraphImage: String = ImgSrc(openGraphImageOrFallbackUrl, openGraphImageProfile)
// These dimensions are just an educated guess (e.g. we don't take into account image-resizer being turned off)
lazy val openGraphImageWidth: Option[Int] = openGraphImageProfile.width
lazy val openGraphImageHeight: Option[Int] =
for {
img <- rawOpenGraphImage
width <- openGraphImageWidth
} yield Math
.round(width / img.ratioDouble)
.toInt // Assume image resizing maintains aspect ratio to calculate height
// URL of image to use in the twitter card. Image must be less than 1MB in size: https://dev.twitter.com/cards/overview
lazy val twitterCardImage = {
val profile = OpenGraphImage.forCategory(shareImageCategory, TwitterShareImageLogoOverlay.isSwitchedOn)
ImgSrc(openGraphImageOrFallbackUrl, profile)
}
lazy val syndicationType: String = {
if (isBlog) {
"blog"
} else if (tags.isGallery) {
"gallery"
} else if (tags.isPodcast) {
"podcast"
} else if (tags.isAudio) {
"audio"
} else if (tags.isVideo) {
"video"
} else {
"article"
}
}
lazy val contributorTwitterHandle: Option[String] = tags.contributors.headOption.flatMap(_.properties.twitterHandle)
lazy val showSectionNotTag: Boolean = {
lazy val isChildrensBookBlog = tags.tags.exists { tag =>
tag.id == "childrens-books-site/childrens-books-site" && tag.properties.tagType == "Blog"
}
lazy val isPaidContent = metadata.commercial.exists(_.isPaidContent)
isChildrensBookBlog || isPaidContent
}
lazy val sectionLabelLink: Option[String] = {
if (showSectionNotTag) {
Some(metadata.sectionId)
} else
tags.tags.find(_.isKeyword).map(_.id)
}
lazy val sectionLabelName: Option[String] = {
if (this.showSectionNotTag) {
Some(trail.sectionName)
} else
tags.tags.find(_.isKeyword).map(_.metadata.webTitle)
}
lazy val blogOrSeriesTag: Option[Tag] = tags.blogOrSeriesTag
lazy val seriesTag: Option[Tag] = {
tags.blogs.find { tag => tag.id != "commentisfree/commentisfree" }.orElse(tags.series.headOption)
}
val seriesName: Option[String] = tags.series.filterNot(_.id == "commentisfree/commentisfree").headOption.map(_.name)
lazy val linkCounts: LinkCounts =
LinkTo.countLinks(fields.body) + fields.standfirst.map(LinkTo.countLinks).getOrElse(LinkCounts.None)
lazy val mainMediaVideo: Option[nodes.Element] =
Jsoup.parseBodyFragment(fields.main).body.getElementsByClass("element-video").asScala.headOption
lazy val mainVideoCanonicalPath: Option[String] = mainMediaVideo.flatMap(video => {
video.attr("data-canonical-url") match {
case url if !url.isEmpty => Some(new URL(url).getPath.stripPrefix("/"))
case _ => None
}
})
lazy val hasMultipleVideosInPage: Boolean = mainMediaVideo match {
case Some(_) => numberOfVideosInTheBody > 0
case None => numberOfVideosInTheBody > 1
}
lazy val numberOfVideosInTheBody: Int =
Jsoup.parseBodyFragment(fields.body).body().children().select("video[class=gu-video]").size()
val legallySensitive: Boolean = fields.legallySensitive.getOrElse(false)
def javascriptConfig: Map[String, JsValue] =
Map(
("contentId", JsString(metadata.id)),
("publication", JsString(publication)),
("hasShowcaseMainElement", JsBoolean(elements.hasShowcaseMainElement)),
("pageCode", JsString(internalPageCode)),
("isContent", JsBoolean(true)),
("wordCount", JsNumber(wordCount)),
("references", JsArray(javascriptReferences)),
(
"showRelatedContent",
JsBoolean(if (tags.isTheMinuteArticle) { false }
else showInRelated && !legallySensitive),
),
("productionOffice", JsString(productionOffice.getOrElse(""))),
("isImmersive", JsBoolean(isImmersive)),
("isColumn", JsBoolean(isColumn)),
("isNumberedList", JsBoolean(isNumberedList)),
("isSplash", JsBoolean(isSplash)),
("isPaidContent", JsBoolean(isPaidContent)),
("campaigns", JsArray(campaigns.map(Campaign.toJson))),
("contributorBio", JsString(contributorBio.getOrElse(""))),
)
def cricketTeam: Option[String] = {
if (tags.isCricketLiveBlog && conf.switches.Switches.CricketScoresSwitch.isSwitchedOn) {
CricketTeams.teamFor(this).map(_.wordsForUrl)
} else None
}
def cricketMatchDate: Option[String] = {
if (tags.isCricketLiveBlog && conf.switches.Switches.CricketScoresSwitch.isSwitchedOn) {
Some(trail.webPublicationDate.withZone(DateTimeZone.UTC).toString("yyyy-MM-dd"))
} else None
}
// Dynamic Meta Data may appear on the page for some content. This should be used for conditional metadata.
def conditionalConfig: Map[String, JsValue] = {
val rugbyMeta = if (tags.isRugbyMatch && conf.switches.Switches.RugbyScoresSwitch.isSwitchedOn) {
val teamIds = tags.keywords.map(_.id).collect(RugbyContent.teamNameIds)
val (team1, team2) = (teamIds.headOption.getOrElse(""), teamIds.lift(1).getOrElse(""))
val date = RugbyContent.timeFormatter.format(Chronos.jodaDateTimeToJavaTimeDateTime(trail.webPublicationDate))
Some(("rugbyMatch", JsString(s"/sport/rugby/api/score/$date/$team1/$team2")))
} else None
val cricketMeta = if (tags.isCricketLiveBlog && conf.switches.Switches.CricketScoresSwitch.isSwitchedOn) {
List(
cricketTeam.map(team => "cricketTeam" -> JsString(team)),
cricketMatchDate.map(date => "cricketMatchDate" -> JsString(date)),
)
} else Nil
val seriesMeta = tags.series.filterNot { _.id == "commentisfree/commentisfree" } match {
case Nil => Nil
case allTags @ (mainSeries :: _) =>
List(
Some("series", JsString(mainSeries.name)),
Some("seriesId", JsString(mainSeries.id)),
Some("seriesTags", JsString(allTags.map(_.name).mkString(","))),
)
}
// Tracking tags are used for things like commissioning desks.
val trackingMeta = tags.tracking match {
case Nil => None
case trackingTags => Some("trackingNames", JsString(trackingTags.map(_.name).mkString(",")))
}
val articleMeta = if (tags.isTheMinuteArticle) {
Some("isMinuteArticle", JsBoolean(tags.isTheMinuteArticle))
} else None
val atomsMeta = atoms.map { atoms =>
val atomIdentifiers = atoms.all.map(atom => JsString(atom.id))
("atoms", JsArray(atomIdentifiers))
}
val atomTypesMeta = atoms.map { atoms =>
("atomTypes", JsObject(atoms.atomTypes.map { case (k, v) => k -> JsBoolean(v) }))
}
// There are many checks that might disable sticky top banner, listed below.
// But if we are in the super sticky banner campaign, we must ignore them!
val canDisableStickyTopBanner =
metadata.shouldHideHeaderAndTopAds ||
isPaidContent ||
metadata.contentType.exists(c => c == DotcomContentType.Interactive || c == DotcomContentType.Crossword)
// These conditions must always disable sticky banner.
val alwaysDisableStickyTopBanner =
shouldHideAdverts ||
metadata.sectionId == "childrens-books-site"
val maybeDisableSticky = canDisableStickyTopBanner match {
case true => Some("disableStickyTopBanner", JsBoolean(true))
case _ if alwaysDisableStickyTopBanner => Some("disableStickyTopBanner", JsBoolean(true))
case _ => None
}
val meta: List[Option[(String, JsValue)]] = List(
rugbyMeta,
articleMeta,
trackingMeta,
atomsMeta,
atomTypesMeta,
maybeDisableSticky,
) ++ cricketMeta ++ seriesMeta
meta.flatten.toMap
}
val opengraphProperties: Map[String, String] = Map(
"og:title" -> StripHtmlTagsAndUnescapeEntities(metadata.webTitle),
"og:description" -> fields.trailText.map(StripHtmlTagsAndUnescapeEntities(_)).getOrElse(""),
"og:image" -> openGraphImage,
) ++ openGraphImageWidth.map("og:image:width" -> _.toString).toMap ++
openGraphImageHeight.map("og:image:height" -> _.toString).toMap
val twitterProperties: Map[String, String] = Map(
"twitter:app:url:googleplay" -> metadata.webUrl
.replaceFirst("^[a-zA-Z]*://", "guardian://"), // replace current scheme with guardian mobile app scheme
"twitter:image" -> twitterCardImage,
"twitter:card" -> "summary_large_image",
) ++ contributorTwitterHandle.map(handle => "twitter:creator" -> s"@$handle").toList
val quizzes: Seq[QuizAtom] = atoms.map(_.quizzes).getOrElse(Nil)
val media: Seq[MediaAtom] = atoms.map(_.media).getOrElse(Nil)
lazy val submetaLinks: SubMetaLinks =
SubMetaLinks.make(isImmersive, tags, blogOrSeriesTag, isFromTheObserver, sectionLabelLink, sectionLabelName)
}
object Content {
def apply(apiContent: contentapi.Content): ContentType = {
val content = make(apiContent)
apiContent match {
case _ if apiContent.isLiveBlog || apiContent.isArticle || apiContent.isSudoku => Article.make(content)
case _ if apiContent.isGallery => Gallery.make(content)
case _ if apiContent.isVideo => Video.make(content)
case _ if apiContent.isAudio => Audio.make(content)
case _ if apiContent.isImageContent => ImageContent.make(content)
case _ => GenericContent(content)
}
}
def make(apiContent: contentapi.Content): Content = {
val fields = Fields.make(apiContent)
val metadata = MetaData.make(fields, apiContent)
val elements = Elements.make(apiContent)
val tags = Tags.make(apiContent)
val commercial = model.Commercial.make(tags, apiContent)
val trail = Trail.make(tags, fields, commercial, elements, metadata, apiContent)
val sharelinks = ShareLinks(tags, fields, metadata)
val atoms = Atoms.make(apiContent, sharelinks.pageShares)
val apifields = apiContent.fields
val references: Map[String, String] =
apiContent.references.map(ref => (ref.`type`, Reference.split(ref.id)._2)).toMap
val cardStyle: fapiutils.CardStyle = CardStylePicker(apiContent)
val schemaOrg = apiContent.schemaOrg
Content(
trail = trail,
metadata = metadata,
tags = tags,
commercial = commercial,
elements = elements,
fields = fields,
sharelinks = sharelinks,
atoms = atoms,
publication = apifields.flatMap(_.publication).getOrElse(""),
internalPageCode = apifields.flatMap(_.internalPageCode).map(_.toString).getOrElse(""),
contributorBio = apifields.flatMap(_.contributorBio),
starRating = apifields.flatMap(_.starRating),
allowUserGeneratedContent = apifields.flatMap(_.allowUgc).getOrElse(false),
isExpired = apiContent.isExpired.getOrElse(false),
productionOffice = apifields.flatMap(_.productionOffice.map(_.name)),
tweets = apiContent.elements.getOrElse(Nil).filter(_.`type`.name == "Tweet").toSeq.map { tweet =>
val images = tweet.assets
.filter(_.`type`.name == "Image")
.flatMap(asset => asset.typeData.flatMap(_.secureFile).orElse(asset.file))
.toSeq
Tweet(tweet.id, images)
},
showInRelated = apifields.flatMap(_.showInRelatedContent).getOrElse(false),
cardStyle = CardStyle.make(cardStyle),
shouldHideAdverts = apifields.flatMap(_.shouldHideAdverts).getOrElse(false),
witnessAssignment = references.get("witness-assignment"),
isbn = references.get("isbn"),
imdb = references.get("imdb"),
paFootballTeams = apiContent.references
.filter(ref => ref.id.contains("pa-football-team"))
.map(ref => ref.id.split("/").last)
.toSeq
.distinct,
javascriptReferences = apiContent.references.toSeq.map(ref => Reference.toJavaScript(ref.id)),
wordCount = Jsoup.clean(fields.body, Safelist.none()).split("\\s+").length,
showByline =
fapiutils.ResolvedMetaData.fromContentAndTrailMetaData(apiContent, TrailMetaData.empty, cardStyle).showByline,
rawOpenGraphImage = FacebookShareUseTrailPicFirstSwitch.isSwitchedOn
.toOption(trail.trailPicture.flatMap(_.largestImage))
.flatten
.orElse(elements.mainPicture.flatMap(_.images.largestImage))
.orElse(trail.trailPicture.flatMap(_.largestImage)),
schemaOrg = schemaOrg,
)
}
}
object ArticleSchemas {
val NewsArticle = "http://schema.org/NewsArticle"
def apply(articleTags: Tags): String = {
// http://schema.org/NewsArticle
// http://schema.org/Review
if (articleTags.isReview)
"http://schema.org/Review"
else if (articleTags.isLiveBlog)
"http://schema.org/LiveBlogPosting"
else
NewsArticle
}
}
object Article {
private def copyMetaData(
content: Content,
commercial: Commercial,
lightbox: GenericLightbox,
trail: Trail,
tags: Tags,
) = {
val contentType: DotcomContentType =
if (content.tags.isLiveBlog) DotcomContentType.LiveBlog else DotcomContentType.Article
val section = content.metadata.sectionId
val fields = content.fields
val bookReviewIsbn = content.isbn.map { i: String => Map("isbn" -> JsString(i)) }.getOrElse(Map())
// we don't serve pre-roll if there are multiple videos in an article
// `headOption` as the video could be main media or a regular embed, so just get the first video
val videoDuration =
content.elements.videos.headOption.map { v => JsNumber(v.videos.duration) }.getOrElse(JsNumber(0))
val javascriptConfig: Map[String, JsValue] = Map(
("isLiveBlog", JsBoolean(content.tags.isLiveBlog)),
("inBodyInternalLinkCount", JsNumber(content.linkCounts.internal)),
("inBodyExternalLinkCount", JsNumber(content.linkCounts.external)),
("shouldHideAdverts", JsBoolean(content.shouldHideAdverts)),
("lightboxImages", lightbox.javascriptConfig),
("hasMultipleVideosInPage", JsBoolean(content.hasMultipleVideosInPage)),
("isImmersive", JsBoolean(content.isImmersive)),
("isHosted", JsBoolean(false)),
("isPhotoEssay", JsBoolean(content.isPhotoEssay)),
("isColumn", JsBoolean(content.isColumn)),
("isNumberedList", JsBoolean(content.isNumberedList)),
("isSplash", JsBoolean(content.isSplash)),
("isSensitive", JsBoolean(fields.sensitive.getOrElse(false))),
"videoDuration" -> videoDuration,
) ++ bookReviewIsbn ++ AtomProperties(content.atoms)
val author = if (tags.contributors.nonEmpty) {
tags.contributors.map(_.metadata.webUrl).mkString(",")
} else {
content.trail.byline.getOrElse("Guardian Staff")
}
val opengraphProperties: Map[String, String] = Map(
("og:type", "article"),
("article:published_time", trail.webPublicationDate.toString()),
("article:modified_time", content.fields.lastModified.toString()),
("article:tag", tags.keywords.map(_.name).mkString(",")),
("article:section", trail.sectionName),
("article:publisher", "https://www.facebook.com/theguardian"),
("article:author", authorOrPA(author)),
)
content.metadata.copy(
contentType = Some(contentType),
adUnitSuffix = section + "/" + contentType.name.toLowerCase,
schemaType = Some(ArticleSchemas(content.tags)),
iosType = Some("Article"),
javascriptConfigOverrides = javascriptConfig,
opengraphPropertiesOverrides = opengraphProperties,
shouldHideHeaderAndTopAds =
(content.tags.isTheMinuteArticle || (content.isImmersive && (content.elements.hasMainMedia || content.fields.main.nonEmpty))) && content.tags.isArticle,
contentWithSlimHeader = content.isImmersive && content.tags.isArticle,
)
}
// Perform a copy of the content object to enable Article to override Content.
def make(content: Content): Article = {
val fields = content.fields
val elements = content.elements
val tags = content.tags
val trail = content.trail
val commercial = content.commercial
val lightboxProperties = GenericLightboxProperties(
lightboxableCutoffWidth = 620,
includeBodyImages = !tags.isLiveBlog,
id = content.metadata.id,
headline = trail.headline,
shouldHideAdverts = content.shouldHideAdverts,
standfirst = fields.standfirst,
)
val lightbox = GenericLightbox(elements, fields, trail, lightboxProperties)
val metadata = copyMetaData(content, commercial, lightbox, trail, tags)
val sharelinks = content.sharelinks
val contentOverrides = content.copy(
trail = trail,
commercial = commercial,
metadata = metadata,
sharelinks = sharelinks,
)
Article(contentOverrides, lightboxProperties)
}
private def authorOrPA: String => String = {
case "Press Association" => "https://www.facebook.com/PAMediaGroupUK/"
case otherwise => otherwise
}
}
final case class Article(override val content: Content, lightboxProperties: GenericLightboxProperties)
extends ContentType {
val lightbox = GenericLightbox(content.elements, content.fields, content.trail, lightboxProperties)
val isLiveBlog: Boolean = content.tags.isLiveBlog && content.fields.blocks.nonEmpty
val isTheMinute: Boolean = content.tags.isTheMinuteArticle
val isImmersive: Boolean = content.isImmersive
val isPhotoEssay: Boolean = content.isPhotoEssay
val isColumn: Boolean = content.isColumn
val isNumberedList: Boolean = content.isNumberedList
val isSplash: Boolean = content.isSplash
lazy val hasVideoAtTop: Boolean = soupedBody
.body()
.children()
.asScala
.headOption
.exists(e => e.hasClass("gu-video") && e.tagName() == "video")
lazy val hasSupporting: Boolean = {
val supportingClasses = Set("element--showcase", "element--supporting", "element--thumbnail")
val leftColElements =
soupedBody.body().select("body > *").asScala.find(_.classNames.asScala.intersect(supportingClasses).nonEmpty)
leftColElements.isDefined
}
private lazy val soupedBody = Jsoup.parseBodyFragment(fields.body)
lazy val hasKeyEvents: Boolean = soupedBody.body().select(".is-key-event").asScala.nonEmpty
lazy val isSport: Boolean = tags.tags.exists(_.id == "sport/sport")
lazy val blocks = content.fields.blocks
}
object Audio {
def make(content: Content): Audio = {
val contentType = DotcomContentType.Audio
val section = content.metadata.sectionId
val javascriptConfig: Map[String, JsValue] = Map("isPodcast" -> JsBoolean(content.tags.isPodcast))
val opengraphProperties = Map(
// Not using the og:video properties here because we want end-users to visit the guardian website
// when they click the thumbnail in the FB feed rather than playing the video "in-place"
"og:type" -> "article",
"article:published_time" -> content.trail.webPublicationDate.toString,
"article:modified_time" -> content.fields.lastModified.toString,
"article:section" -> content.trail.sectionName,
"article:tag" -> content.tags.keywords.map(_.name).mkString(","),
)
val metadata = content.metadata.copy(
contentType = Some(contentType),
adUnitSuffix = section + "/" + contentType.name.toLowerCase,
schemaType = Some("https://schema.org/AudioObject"),
javascriptConfigOverrides = javascriptConfig,
opengraphPropertiesOverrides = opengraphProperties,
)
val contentOverrides = content.copy(
metadata = metadata,
)
Audio(contentOverrides)
}
}
final case class Audio(override val content: Content) extends ContentType {
lazy val downloadUrl: Option[String] = elements.mainAudio
.flatMap(_.audio.encodings.find(_.format == "audio/mpeg").map(_.url))
lazy val duration: Option[Int] = elements.mainAudio.map(_.audio.duration)
private lazy val podcastTag: Option[Tag] = tags.tags.find(_.properties.podcast.nonEmpty)
lazy val iTunesSubscriptionUrl: Option[String] = podcastTag.flatMap(_.properties.podcast.flatMap(_.subscriptionUrl))
lazy val spotifyUrl: Option[String] = podcastTag.flatMap(_.properties.podcast.flatMap(_.spotifyUrl))
lazy val seriesFeedUrl: Option[String] = podcastTag.map(tag => s"/${tag.id}/podcast.xml")
}
object AtomProperties {
def hasYouTubeAtom(atoms: Option[Atoms]): Boolean = {
val hasYouTubeAtom: Option[Boolean] =
atoms.map(_.media.exists(_.assets.exists(_.platform == MediaAssetPlatform.Youtube)))
hasYouTubeAtom.getOrElse(false)
}
def apply(atoms: Option[Atoms]): Map[String, JsBoolean] = {
Map("hasYouTubeAtom" -> JsBoolean(hasYouTubeAtom(atoms)))
}
}
object Video {
def make(content: Content): Video = {
val contentType = DotcomContentType.Video
val elements = content.elements
val section = content.metadata.sectionId
val source = elements.videos
.find(_.properties.isMain)
.flatMap(_.videos.source)
.orElse(content.media.headOption.flatMap(_.source))
val javascriptConfig: Map[String, JsValue] = Map(
"isPodcast" -> JsBoolean(content.tags.isPodcast),
"source" -> JsString(source.getOrElse("")),
"embeddable" -> JsBoolean(elements.videos.find(_.properties.isMain).exists(_.videos.embeddable)),
"videoDuration" -> elements.videos
.find(_.properties.isMain)
.map { v => JsNumber(v.videos.duration) }
.getOrElse(JsNumber(0)),
) ++ AtomProperties(content.atoms)
val optionalOpengraphProperties =
if (content.metadata.webUrl.startsWith("https://"))
Map(
"og:video:url" -> content.metadata.webUrl,
"og:video:secure_url" -> content.metadata.webUrl,
)
else Map.empty
val opengraphProperties = Map(
// Not using the og:video properties here because we want end-users to visit the guardian website
// when they click the thumbnail in the FB feed rather than playing the video "in-place"
"og:type" -> "article",
"article:published_time" -> content.trail.webPublicationDate.toString,
"article:modified_time" -> content.fields.lastModified.toString,
"article:section" -> content.trail.sectionName,
"article:tag" -> content.tags.keywords.map(_.name).mkString(","),
) ++ optionalOpengraphProperties
val metadata = content.metadata.copy(
contentType = Some(contentType),
adUnitSuffix = section + "/" + contentType.name.toLowerCase,
schemaType = Some("http://schema.org/VideoObject"),
javascriptConfigOverrides = javascriptConfig,
opengraphPropertiesOverrides = opengraphProperties,
)
val contentOverrides = content.copy(
metadata = metadata,
)
Video(contentOverrides, source, content.media.headOption)
}
}
final case class Video(override val content: Content, source: Option[String], mediaAtom: Option[MediaAtom])
extends ContentType {
lazy val bylineWithSource: Option[String] = {
val videoSource: Option[String] = source.orElse(mediaAtom.flatMap(_.source))
def prettySource(source: String): String =
source match {
case "guardian.co.uk" => "theguardian.com"
case other if other.nonEmpty => s"Source: $other"
}
(trail.byline, videoSource) match {
case (Some(b), Some(s)) if b.nonEmpty && s.nonEmpty => Some(s"$b, ${prettySource(s)}")
case (Some(b), _) if b.nonEmpty => Some(b)
case (_, Some(s)) if s.nonEmpty => Some(prettySource(s))
case _ => None
}
}
lazy val videoLinkText: String = {
val suffixVariations = List(
" - video",
" – video",
" - video interview",
" – video interview",
" - video interviews",
" – video interviews",
)
suffixVariations.fold(trail.headline.trim) { (str, suffix) => str.stripSuffix(suffix) }
}
def sixteenByNineMetaImage: Option[String] =
for {
imageMedia <- mediaAtom.flatMap(_.posterImage) orElse content.elements.thumbnail.map(_.images)
videoProfile <- Video1280.bestSrcFor(imageMedia)
} yield videoProfile
}
object Gallery {
def make(content: Content): Gallery = {
val contentType = DotcomContentType.Gallery
val fields = content.fields
val elements = content.elements
val tags = content.tags
val section = content.metadata.sectionId
val id = content.metadata.id
val lightboxProperties = GalleryLightboxProperties(
id = id,
headline = content.trail.headline,
shouldHideAdverts = content.shouldHideAdverts,
standfirst = fields.standfirst,
)
val lightbox = GalleryLightbox(elements, tags, lightboxProperties)
val javascriptConfig: Map[String, JsValue] = Map(
"gallerySize" -> JsNumber(lightbox.size),
"lightboxImages" -> lightbox.javascriptConfig,
)
val trail = content.trail.copy(trailPicture = elements.thumbnail.map(_.images))
val openGraph: Map[String, String] = Map(
"og:type" -> "article",
"article:published_time" -> trail.webPublicationDate.toString,
"article:modified_time" -> content.fields.lastModified.toString,
"article:section" -> trail.sectionName,
"article:tag" -> tags.keywords.map(_.name).mkString(","),
"article:author" -> tags.contributors.map(_.metadata.webUrl).mkString(","),
)
val metadata = content.metadata.copy(
contentType = Some(contentType),
adUnitSuffix = section + "/" + contentType.name.toLowerCase,
schemaType = Some("https://schema.org/ImageGallery"),
openGraphImages = lightbox.openGraphImages,
javascriptConfigOverrides = javascriptConfig,
twitterPropertiesOverrides = Map("twitter:title" -> fields.linkText),
opengraphPropertiesOverrides = openGraph,
contentWithSlimHeader = true,
)
val contentOverrides = content.copy(
metadata = metadata,
trail = trail,
rawOpenGraphImage = FacebookShareUseTrailPicFirstSwitch.isSwitchedOn
.toOption(trail.trailPicture.flatMap(_.largestImage))
.flatten
.orElse(lightbox.galleryImages.headOption.flatMap(_.images.largestImage)),
)
Gallery(contentOverrides, lightboxProperties)
}
}
final case class Gallery(override val content: Content, lightboxProperties: GalleryLightboxProperties)
extends ContentType {
val lightbox = GalleryLightbox(content.elements, content.tags, lightboxProperties)
def apply(index: Int): ImageAsset = lightbox.galleryImages(index).images.largestImage.get
}
case class GalleryLightboxProperties(
id: String,
headline: String,
shouldHideAdverts: Boolean,
standfirst: Option[String],
)
case class GalleryLightbox(
elements: Elements,
tags: Tags,
properties: GalleryLightboxProperties,
) {
def imageContainer(index: Int): ImageElement = galleryImages(index)
private val facebookImage: ElementProfile = {
val category =
if (tags.isComment) GuardianOpinion
else if (tags.isLiveBlog) Live
else GuardianDefault
OpenGraphImage.forCategory(category, FacebookShareImageLogoOverlay.isSwitchedOn)
}
val galleryImages: Seq[ImageElement] = elements.images.filter(_.properties.isGallery)
val largestCrops: Seq[ImageAsset] = galleryImages.flatMap(_.images.largestImage)
val openGraphImages: Seq[String] = largestCrops.flatMap(_.url).map(ImgSrc(_, facebookImage))
val size = galleryImages.size
val landscapes = largestCrops.filter(i => i.width > i.height).sortBy(_.index)
val portraits = largestCrops.filter(i => i.width < i.height).sortBy(_.index)
val isInPicturesSeries = tags.tags.exists(_.id == "lifeandstyle/series/in-pictures")
lazy val containsAffiliateableLinks: Boolean =
largestCrops.flatMap(_.caption.map(AffiliateLinksCleaner.stringContainsAffiliateableLinks)).contains(true)
val javascriptConfig: JsObject = {
val imageJson = for {
container <- galleryImages
img <- container.images.largestEditorialCrop
} yield {
JsObject(
Seq(
"caption" -> JsString(img.caption.getOrElse("")),
"credit" -> JsString(img.credit.getOrElse("")),
"displayCredit" -> JsBoolean(img.displayCredit),
"src" -> JsString(Item700.bestSrcFor(container.images).getOrElse("")),
"srcsets" -> JsString(ImgSrc.srcset(container.images, GalleryMedia.lightbox)),
"sizes" -> JsString(GalleryMedia.lightbox.sizes),
"ratio" -> Try(JsNumber(img.width.toDouble / img.height.toDouble)).getOrElse(JsNumber(1)),
"role" -> JsString(img.role.toString),
),
)
}
JsObject(
Seq(
"id" -> JsString(properties.id),
"headline" -> JsString(properties.headline),
"shouldHideAdverts" -> JsBoolean(properties.shouldHideAdverts),
"standfirst" -> JsString(properties.standfirst.getOrElse("")),
"images" -> JsArray(imageJson),
),
)
}
}
case class GenericLightboxProperties(
id: String,
headline: String,
shouldHideAdverts: Boolean,
standfirst: Option[String],
lightboxableCutoffWidth: Int,
includeBodyImages: Boolean,
)
case class GenericLightbox(
elements: Elements,
fields: Fields,
trail: Trail,
properties: GenericLightboxProperties,
) {
lazy val mainFiltered = elements.mainPicture
.filter(_.images.largestEditorialCrop.map(_.width).getOrElse(1) > properties.lightboxableCutoffWidth)
.toSeq
lazy val bodyFiltered: Seq[ImageElement] = elements.bodyImages.filter(
_.images.largestEditorialCrop.map(_.width).getOrElse(1) > properties.lightboxableCutoffWidth,
)
val lightboxImages = if (properties.includeBodyImages) mainFiltered ++ bodyFiltered else mainFiltered
lazy val isMainMediaLightboxable = mainFiltered.nonEmpty
lazy val javascriptConfig: JsObject = {
val imageJson = for {
container <- lightboxImages
img <- container.images.largestEditorialCrop
} yield {
JsObject(
Seq(
"caption" -> JsString(img.caption.getOrElse("")),
"credit" -> JsString(img.credit.getOrElse("")),
"displayCredit" -> JsBoolean(img.displayCredit),
"src" -> JsString(Item700.bestSrcFor(container.images).getOrElse("")),
"srcsets" -> JsString(ImgSrc.srcset(container.images, GalleryMedia.lightbox)),
"sizes" -> JsString(GalleryMedia.lightbox.sizes),
"ratio" -> Try(JsNumber(img.width.toDouble / img.height.toDouble)).getOrElse(JsNumber(1)),
"role" -> JsString(img.role.toString),
"parentContentId" -> JsString(properties.id),
"id" -> JsString(properties.id), // duplicated to simplify lightbox logic
),
)
}
JsObject(
Seq(
"id" -> JsString(properties.id),
"headline" -> JsString(properties.headline),
"shouldHideAdverts" -> JsBoolean(properties.shouldHideAdverts),
"standfirst" -> JsString(properties.standfirst.getOrElse("")),
"images" -> JsArray(imageJson),
),
)
}
}
final case class Interactive(override val content: Content, maybeBody: Option[String]) extends ContentType {
lazy val fallbackEl = {
val noscriptEls = Jsoup.parseBodyFragment(fields.body).getElementsByTag("noscript")
if (noscriptEls.asScala.nonEmpty) {
noscriptEls.html()
} else {
Jsoup.parseBodyFragment(fields.body).getElementsByTag("figure").html()
}
}
lazy val figureEl = maybeBody.map(Jsoup.parseBodyFragment(_).getElementsByTag("figure").html("").outerHtml())
}
object Interactive {
def make(apiContent: contentapi.Content): Interactive = {
val content = Content(apiContent).content
val contentType = DotcomContentType.Interactive
val fields = content.fields
val section = content.metadata.sectionId
val opengraphProperties: Map[String, String] = Map(
("og:type", "article"),
("article:published_time", content.trail.webPublicationDate.toString()),
("article:modified_time", content.fields.lastModified.toString()),
("article:tag", content.tags.keywords.map(_.name).mkString(",")),
("article:section", content.trail.sectionName),
("article:publisher", "https://www.facebook.com/theguardian"),
)
val metadata = content.metadata.copy(
contentType = Some(contentType),
adUnitSuffix = section + "/" + contentType.name.toLowerCase,
twitterPropertiesOverrides = Map("twitter:title" -> fields.linkText),
contentWithSlimHeader = InteractiveHeaderSwitch.isSwitchedOff,
opengraphPropertiesOverrides = opengraphProperties,
)
val contentOverrides = content.copy(
metadata = metadata,
)
Interactive(contentOverrides, maybeBody = apiContent.fields.flatMap(_.body))
}
}
object ImageContent {
def make(content: Content): ImageContent = {
val contentType = DotcomContentType.ImageContent
val fields = content.fields
val section = content.metadata.sectionId
val id = content.metadata.id
val lightboxProperties = GenericLightboxProperties(
lightboxableCutoffWidth = 940,
includeBodyImages = false,
id = id,
headline = content.trail.headline,
shouldHideAdverts = content.shouldHideAdverts,
standfirst = fields.standfirst,
)
val lightbox = GenericLightbox(content.elements, content.fields, content.trail, lightboxProperties)
val javascriptConfig: Map[String, JsValue] = Map(
"lightboxImages" -> lightbox.javascriptConfig,
)
val metadata = content.metadata.copy(
contentType = Some(contentType),
adUnitSuffix = section + "/" + contentType.name.toLowerCase,
javascriptConfigOverrides = javascriptConfig,
)
val contentOverrides = content.copy(
metadata = metadata,
)
ImageContent(contentOverrides, lightboxProperties)
}
}
final case class ImageContent(override val content: Content, lightboxProperties: GenericLightboxProperties)
extends ContentType {
val lightBox = GenericLightbox(content.elements, content.fields, content.trail, lightboxProperties)
}
object CrosswordContent {
def make(crossword: CrosswordData, apicontent: contentapi.Content): CrosswordContent = {
val content = Content(apicontent)
val contentType = DotcomContentType.Crossword
val metadata = content.metadata.copy(
id = crossword.id,
section = Some(SectionId.fromId("crosswords")),
contentType = Some(contentType),
iosType = None,
)
val contentOverrides = content.content.copy(metadata = metadata)
CrosswordContent(contentOverrides, crossword)
}
}
final case class CrosswordContent(override val content: Content, crossword: CrosswordData) extends ContentType
case class Tweet(id: String, images: Seq[String]) {
val firstImage: Option[String] = images.headOption
}