게시물 광고 배치 조정
This commit is contained in:
@@ -15,7 +15,13 @@ const adSlots = [
|
||||
{
|
||||
key: 'adSidebarCode',
|
||||
label: '오른쪽 사이드',
|
||||
description: '오른쪽 사이드바 하단 영역에 표시됩니다.',
|
||||
description: '게시물 상세를 제외한 공개 화면의 오른쪽 사이드바 하단에 표시됩니다.',
|
||||
placeholder: '<ins class="adsbygoogle" ...></ins>'
|
||||
},
|
||||
{
|
||||
key: 'adPostSidebarCode',
|
||||
label: '게시물 왼쪽 사이드',
|
||||
description: '게시물 상세 화면의 왼쪽 사이드바 하단에 표시됩니다.',
|
||||
placeholder: '<ins class="adsbygoogle" ...></ins>'
|
||||
},
|
||||
{
|
||||
@@ -27,7 +33,7 @@ const adSlots = [
|
||||
{
|
||||
key: 'adPostInArticleCode',
|
||||
label: '게시물 인아티클',
|
||||
description: '게시물 본문이 충분히 길 때 전체 블록 40% 근처 문단 뒤에 한 번 표시됩니다.',
|
||||
description: '게시물 본문이 충분히 길 때 본문 중간 문단 뒤에 표시되며, 긴 글은 최대 두 번 표시됩니다.',
|
||||
placeholder: '<ins class="adsbygoogle" ...></ins>'
|
||||
},
|
||||
{
|
||||
|
||||
@@ -92,6 +92,12 @@ const BLANK_PARAGRAPH_MARKER = '<!--sori:blank-paragraph-->'
|
||||
const LIVE_IMAGE_DRAG_MIME = 'application/x-sori-live-image'
|
||||
/** @type {string} 본문 리스트 마커 색상 */
|
||||
const CONTENT_LIST_MARKER_COLOR = '#2eb6ea'
|
||||
/** @type {number} 인아티클 광고 최소 본문 글자 수 */
|
||||
const IN_ARTICLE_AD_MIN_TEXT_LENGTH = 2000
|
||||
/** @type {number} 인아티클 광고 2회 표시 기준 본문 글자 수 */
|
||||
const IN_ARTICLE_AD_SECOND_TEXT_LENGTH = 6000
|
||||
/** @type {number} 인아티클 광고 사이 최소 블록 간격 */
|
||||
const IN_ARTICLE_AD_MIN_BLOCK_SPACING = 8
|
||||
|
||||
const activeLightboxImages = ref([])
|
||||
const activeLightboxIndex = ref(0)
|
||||
@@ -784,19 +790,38 @@ const isInArticleAdCandidateBlock = (block) => {
|
||||
}
|
||||
|
||||
/**
|
||||
* 본문 인아티클 광고를 삽입할 블록 ID를 반환한다.
|
||||
* @returns {string} 삽입 기준 블록 ID
|
||||
* 본문 인아티클 광고 목표 위치 비율 목록을 반환한다.
|
||||
* @param {number} plainTextLength - 본문 글자 수
|
||||
* @returns {number[]} 광고 목표 위치 비율
|
||||
*/
|
||||
const inArticleAdAfterBlockId = computed(() => {
|
||||
const getInArticleAdTargetRatios = (plainTextLength) => {
|
||||
if (plainTextLength >= IN_ARTICLE_AD_SECOND_TEXT_LENGTH) {
|
||||
return [0.35, 0.7]
|
||||
}
|
||||
|
||||
if (plainTextLength >= IN_ARTICLE_AD_MIN_TEXT_LENGTH) {
|
||||
return [0.4]
|
||||
}
|
||||
|
||||
return []
|
||||
}
|
||||
|
||||
/**
|
||||
* 본문 인아티클 광고를 삽입할 블록 ID 목록을 반환한다.
|
||||
* @returns {string[]} 삽입 기준 블록 ID 목록
|
||||
*/
|
||||
const inArticleAdAfterBlockIds = computed(() => {
|
||||
if (props.interactive || !normalizedInArticleAdCode.value) {
|
||||
return ''
|
||||
return []
|
||||
}
|
||||
|
||||
const currentBlocks = blocks.value
|
||||
const contentBlocks = currentBlocks.filter((block) => !['spacer', 'divider'].includes(block.type))
|
||||
const plainTextLength = contentBlocks.reduce((sum, block) => sum + getBlockPlainTextLength(block), 0)
|
||||
const targetRatios = getInArticleAdTargetRatios(plainTextLength)
|
||||
|
||||
if (contentBlocks.length < 10) {
|
||||
return ''
|
||||
if (contentBlocks.length < 10 || !targetRatios.length) {
|
||||
return []
|
||||
}
|
||||
|
||||
const candidates = currentBlocks
|
||||
@@ -804,29 +829,48 @@ const inArticleAdAfterBlockId = computed(() => {
|
||||
.filter((item) => isInArticleAdCandidateBlock(item.block))
|
||||
|
||||
if (candidates.length < 6) {
|
||||
return ''
|
||||
return []
|
||||
}
|
||||
|
||||
const minIndex = Math.max(3, Math.floor(currentBlocks.length * 0.2))
|
||||
const maxIndex = Math.min(currentBlocks.length - 4, Math.floor(currentBlocks.length * 0.75))
|
||||
const maxIndex = Math.min(currentBlocks.length - 4, Math.floor(currentBlocks.length * 0.8))
|
||||
|
||||
if (maxIndex < minIndex) {
|
||||
return ''
|
||||
return []
|
||||
}
|
||||
|
||||
const targetIndex = Math.floor((currentBlocks.length - 1) * 0.4)
|
||||
const rangedCandidates = candidates.filter((item) => item.index >= minIndex && item.index <= maxIndex)
|
||||
|
||||
if (!rangedCandidates.length) {
|
||||
return ''
|
||||
return []
|
||||
}
|
||||
|
||||
const selected = rangedCandidates.reduce((closest, item) => {
|
||||
return Math.abs(item.index - targetIndex) < Math.abs(closest.index - targetIndex) ? item : closest
|
||||
}, rangedCandidates[0])
|
||||
const selectedItems = []
|
||||
|
||||
return selected.block.id
|
||||
for (const ratio of targetRatios) {
|
||||
const targetIndex = Math.floor((currentBlocks.length - 1) * ratio)
|
||||
const availableCandidates = rangedCandidates.filter((item) => {
|
||||
return selectedItems.every((selected) => Math.abs(selected.index - item.index) >= IN_ARTICLE_AD_MIN_BLOCK_SPACING)
|
||||
})
|
||||
|
||||
if (!availableCandidates.length) {
|
||||
continue
|
||||
}
|
||||
|
||||
const selected = availableCandidates.reduce((closest, item) => {
|
||||
return Math.abs(item.index - targetIndex) < Math.abs(closest.index - targetIndex) ? item : closest
|
||||
}, availableCandidates[0])
|
||||
|
||||
selectedItems.push(selected)
|
||||
}
|
||||
|
||||
return selectedItems
|
||||
.sort((a, b) => a.index - b.index)
|
||||
.map((item) => item.block.id)
|
||||
})
|
||||
|
||||
const inArticleAdAfterBlockIdSet = computed(() => new Set(inArticleAdAfterBlockIds.value))
|
||||
|
||||
const headingIdsByBlock = computed(() => {
|
||||
const createHeadingId = createHeadingIdFactory()
|
||||
const ids = {}
|
||||
@@ -3331,7 +3375,7 @@ onBeforeUnmount(() => {
|
||||
</p>
|
||||
|
||||
<SiteAdSlot
|
||||
v-if="block.id === inArticleAdAfterBlockId"
|
||||
v-if="inArticleAdAfterBlockIdSet.has(block.id)"
|
||||
class="content-markdown-renderer__in-article-ad my-8"
|
||||
:code="normalizedInArticleAdCode"
|
||||
location="post-in-article"
|
||||
|
||||
@@ -7,6 +7,13 @@ defineProps({
|
||||
})
|
||||
|
||||
const { isDarkMode, toggleTheme } = useThemeMode()
|
||||
const route = useRoute()
|
||||
|
||||
const { data: siteSettings } = await useFetch('/api/site-settings', {
|
||||
default: () => ({
|
||||
adPostSidebarCode: ''
|
||||
})
|
||||
})
|
||||
|
||||
const { data: tags } = await useFetch('/api/tags', {
|
||||
default: () => []
|
||||
@@ -22,6 +29,7 @@ const { data: navigation } = await useFetch('/api/navigation', {
|
||||
|
||||
/** 저자 영역 공개 여부 */
|
||||
const showAuthorSection = false
|
||||
const isPostDetailRoute = computed(() => route.path.startsWith('/post/'))
|
||||
|
||||
const STORAGE_KEY = 'sori-primary-nav-expanded'
|
||||
|
||||
@@ -193,6 +201,13 @@ onMounted(() => {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SiteAdSlot
|
||||
v-if="isPostDetailRoute"
|
||||
class="left-sidebar__post-ad-slot site-sidebar-section px-5 py-5 pr-3 max-lg:hidden xl:pl-0"
|
||||
:code="siteSettings?.adPostSidebarCode"
|
||||
location="post-sidebar-left"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<footer class="left-sidebar__footer flex shrink-0 flex-wrap items-center justify-between gap-x-3 gap-y-2 px-4 py-4 text-xs sm:px-5">
|
||||
|
||||
@@ -39,6 +39,7 @@ const recommendedSites = computed(() => {
|
||||
return list.filter((x) => x?.isVisible !== false)
|
||||
})
|
||||
const isPostDetailRoute = computed(() => route.path.startsWith('/post/'))
|
||||
const sidebarAdCode = computed(() => isPostDetailRoute.value ? '' : siteSettings.value?.adSidebarCode)
|
||||
const postTocItems = computed(() => Array.isArray(postToc.value) ? postToc.value : [])
|
||||
const followLinks = computed(() => getVisibleSocialLinks(siteSettings.value?.socialLinks || []))
|
||||
|
||||
@@ -489,7 +490,7 @@ watch([postTocItems, () => route.fullPath], async () => {
|
||||
|
||||
<SiteAdSlot
|
||||
class="right-sidebar__ad-slot site-sidebar-section py-5 pl-5 pr-0 max-lg:px-0"
|
||||
:code="siteSettings?.adSidebarCode"
|
||||
:code="sidebarAdCode"
|
||||
location="sidebar"
|
||||
/>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user