From 629ef8c4c69227619715129d2fd52043ed6d4593 Mon Sep 17 00:00:00 2001
From: zenn
Date: Fri, 5 Jun 2026 16:32:29 +0900
Subject: [PATCH] =?UTF-8?q?=EA=B2=8C=EC=8B=9C=EB=AC=BC=20=EA=B4=91?=
=?UTF-8?q?=EA=B3=A0=20=EB=B0=B0=EC=B9=98=20=EC=A1=B0=EC=A0=95?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
components/admin/AdminAdsSettingsCard.vue | 10 ++-
.../content/ContentMarkdownRenderer.vue | 76 +++++++++++++++----
components/site/LeftSidebar.vue | 15 ++++
components/site/RightSidebar.vue | 3 +-
.../053_site_settings_post_sidebar_ad.sql | 2 +
docs/deploy.md | 9 ++-
docs/history.md | 4 +
docs/map.md | 10 +--
docs/spec.md | 6 +-
docs/update.md | 6 ++
package-lock.json | 4 +-
package.json | 2 +-
pages/admin/settings/index.vue | 7 ++
server/repositories/content-repository.js | 4 +
server/utils/admin-site-settings-input.js | 1 +
server/utils/site-settings.js | 1 +
16 files changed, 129 insertions(+), 31 deletions(-)
create mode 100644 db/migrations/053_site_settings_post_sidebar_ad.sql
diff --git a/components/admin/AdminAdsSettingsCard.vue b/components/admin/AdminAdsSettingsCard.vue
index 0f961d6..b98263e 100644
--- a/components/admin/AdminAdsSettingsCard.vue
+++ b/components/admin/AdminAdsSettingsCard.vue
@@ -15,7 +15,13 @@ const adSlots = [
{
key: 'adSidebarCode',
label: '오른쪽 사이드',
- description: '오른쪽 사이드바 하단 영역에 표시됩니다.',
+ description: '게시물 상세를 제외한 공개 화면의 오른쪽 사이드바 하단에 표시됩니다.',
+ placeholder: ''
+ },
+ {
+ key: 'adPostSidebarCode',
+ label: '게시물 왼쪽 사이드',
+ description: '게시물 상세 화면의 왼쪽 사이드바 하단에 표시됩니다.',
placeholder: ''
},
{
@@ -27,7 +33,7 @@ const adSlots = [
{
key: 'adPostInArticleCode',
label: '게시물 인아티클',
- description: '게시물 본문이 충분히 길 때 전체 블록 40% 근처 문단 뒤에 한 번 표시됩니다.',
+ description: '게시물 본문이 충분히 길 때 본문 중간 문단 뒤에 표시되며, 긴 글은 최대 두 번 표시됩니다.',
placeholder: ''
},
{
diff --git a/components/content/ContentMarkdownRenderer.vue b/components/content/ContentMarkdownRenderer.vue
index 7b511ac..4436bb4 100644
--- a/components/content/ContentMarkdownRenderer.vue
+++ b/components/content/ContentMarkdownRenderer.vue
@@ -92,6 +92,12 @@ const BLANK_PARAGRAPH_MARKER = ''
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(() => {
({
+ 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(() => {
+
+