인아티클 광고 위치 계산 분리

This commit is contained in:
2026-07-24 14:23:46 +09:00
parent d764865220
commit 1f68524548
7 changed files with 172 additions and 98 deletions

View File

@@ -47,6 +47,7 @@ import {
QUOTE_BACKGROUND_OPTIONS,
parseCalloutOptions
} from '../../lib/markdown-callout.js'
import { getInArticleAdAfterBlockIds } from '../../lib/content-in-article-ad.js'
import { createHeadingIdFactory } from '../../lib/markdown-toc.js'
import ContentMarkdownCodeBlockEditor from './ContentMarkdownCodeBlockEditor.vue'
import ProseCodeBlock from './ProseCodeBlock.vue'
@@ -105,13 +106,6 @@ 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)
/** @type {import('vue').Ref<Record<string, number>>} 갤러리 이미지 자연 비율 */
@@ -621,42 +615,6 @@ const parseMarkdownBlocks = (markdown) => {
const blocks = computed(() => parseMarkdownBlocks(props.content))
const normalizedInArticleAdCode = computed(() => String(props.inArticleAdCode || '').trim())
/**
* 블록의 텍스트 길이를 계산한다.
* @param {Object} block - 마크다운 블록
* @returns {number} 공백을 제외한 텍스트 길이
*/
const getBlockPlainTextLength = (block) => {
const value = Array.isArray(block.text) ? block.text.join(' ') : String(block.text || '')
return value.replace(/\s+/g, '').length
}
/**
* 본문 인아티클 광고를 삽입할 수 있는 문단 블록인지 확인한다.
* @param {Object} block - 마크다운 블록
* @returns {boolean} 삽입 후보 여부
*/
const isInArticleAdCandidateBlock = (block) => {
return block?.type === 'paragraph' && getBlockPlainTextLength(block) >= 12
}
/**
* 본문 인아티클 광고 목표 위치 비율 목록을 반환한다.
* @param {number} plainTextLength - 본문 글자 수
* @returns {number[]} 광고 목표 위치 비율
*/
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 목록
@@ -666,58 +624,7 @@ const inArticleAdAfterBlockIds = computed(() => {
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 || !targetRatios.length) {
return []
}
const candidates = currentBlocks
.map((block, index) => ({ block, index }))
.filter((item) => isInArticleAdCandidateBlock(item.block))
if (candidates.length < 6) {
return []
}
const minIndex = Math.max(3, Math.floor(currentBlocks.length * 0.2))
const maxIndex = Math.min(currentBlocks.length - 4, Math.floor(currentBlocks.length * 0.8))
if (maxIndex < minIndex) {
return []
}
const rangedCandidates = candidates.filter((item) => item.index >= minIndex && item.index <= maxIndex)
if (!rangedCandidates.length) {
return []
}
const selectedItems = []
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)
return getInArticleAdAfterBlockIds(blocks.value)
})
const headingIdsByBlock = computed(() => {

View File

@@ -32,6 +32,7 @@
| 파일 | 용도 |
|------|------|
| lib/upload-size-limit.js | 관리자 미디어 업로드 종류별 최대 바이트 판별·1회 파일 수/총량 제한 정규화·용량 문구(서버·클라이언트 공용) |
| lib/content-in-article-ad.js | 공개 Markdown 본문 인아티클 광고 삽입 후보·목표 위치 계산 |
| lib/admin-social-settings-section.js | 관리자 설정 SNS 설정 필드 목록·기본 스냅샷·정규화·스냅샷 복사·변경 비교 helper |
| lib/admin-post-settings-section.js | 관리자 설정 POST 설정 필드 목록·기본 스냅샷·정규화·스냅샷 복사·변경 비교 helper |
| lib/admin-spam-settings-section.js | 관리자 설정 스팸 필터 필드 목록·기본 스냅샷·정규화·textarea 변환·스냅샷 복사·변경 비교 helper |
@@ -106,6 +107,7 @@
| playwright.config.js | Playwright API/E2E 프로젝트, 단일 worker, 전용 Nuxt 서버·trace 설정 |
| docker-compose.test.yml | 실행마다 생성·폐기하는 PostgreSQL 16 테스트 DB·볼륨·네트워크 |
| tests/markdown-html-block.test.js | HTML sanitizer 단위 회귀 테스트 |
| tests/content-in-article-ad.test.js | 공개 본문 인아티클 광고 삽입 위치 계산 단위 회귀 테스트 |
| tests/markdown-fenced-meta.test.js | Markdown fenced 블록 본문 수집과 메타 파싱 단위 회귀 테스트 |
| tests/multipart-file-upload.test.js | multipart 스트리밍·제한 단위 회귀 테스트 |
| tests/csp-report.test.js | CSP URL 최소화·payload 형식 단위 회귀 테스트 |

View File

@@ -1,5 +1,11 @@
# 업데이트 이력
## v1.11.50
- 공개 Markdown 렌더러의 인아티클 광고 삽입 위치 계산을 `lib/content-in-article-ad.js`로 분리.
- 본문 길이별 광고 목표 위치와 문단 후보 판별 단위 테스트를 추가.
- `ContentMarkdownRenderer`는 광고 코드 표시 여부와 렌더 블록 삽입 연결만 담당하도록 정리.
## v1.11.49
- 공개 Markdown 렌더러의 fenced 블록 본문 수집과 북마크·회원가입·미디어 메타 파싱을 `lib/markdown-fenced-meta.js`로 분리.

View File

@@ -0,0 +1,104 @@
/** @type {number} 인아티클 광고 최소 본문 글자 수 */
export const IN_ARTICLE_AD_MIN_TEXT_LENGTH = 2000
/** @type {number} 인아티클 광고 2회 표시 기준 본문 글자 수 */
export const IN_ARTICLE_AD_SECOND_TEXT_LENGTH = 6000
/** @type {number} 인아티클 광고 사이 최소 블록 간격 */
export const IN_ARTICLE_AD_MIN_BLOCK_SPACING = 8
/**
* 블록의 텍스트 길이를 계산한다.
* @param {Object} block - 마크다운 블록
* @returns {number} 공백을 제외한 텍스트 길이
*/
export const getBlockPlainTextLength = (block) => {
const value = Array.isArray(block?.text) ? block.text.join(' ') : String(block?.text || '')
return value.replace(/\s+/g, '').length
}
/**
* 본문 인아티클 광고를 삽입할 수 있는 문단 블록인지 확인한다.
* @param {Object} block - 마크다운 블록
* @returns {boolean} 삽입 후보 여부
*/
export const isInArticleAdCandidateBlock = (block) => {
return block?.type === 'paragraph' && getBlockPlainTextLength(block) >= 12
}
/**
* 본문 인아티클 광고 목표 위치 비율 목록을 반환한다.
* @param {number} plainTextLength - 본문 글자 수
* @returns {number[]} 광고 목표 위치 비율
*/
export 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 목록을 반환한다.
* @param {Object[]} blocks - 렌더링 블록 목록
* @returns {string[]} 삽입 기준 블록 ID 목록
*/
export const getInArticleAdAfterBlockIds = (blocks) => {
const currentBlocks = Array.isArray(blocks) ? blocks : []
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 || !targetRatios.length) {
return []
}
const candidates = currentBlocks
.map((block, index) => ({ block, index }))
.filter((item) => isInArticleAdCandidateBlock(item.block))
if (candidates.length < 6) {
return []
}
const minIndex = Math.max(3, Math.floor(currentBlocks.length * 0.2))
const maxIndex = Math.min(currentBlocks.length - 4, Math.floor(currentBlocks.length * 0.8))
if (maxIndex < minIndex) {
return []
}
const rangedCandidates = candidates.filter((item) => item.index >= minIndex && item.index <= maxIndex)
if (!rangedCandidates.length) {
return []
}
const selectedItems = []
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)
}

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "sori.studio",
"version": "1.11.49",
"version": "1.11.50",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "sori.studio",
"version": "1.11.49",
"version": "1.11.50",
"hasInstallScript": true,
"dependencies": {
"@nuxtjs/tailwindcss": "^6.14.0",

View File

@@ -1,6 +1,6 @@
{
"name": "sori.studio",
"version": "1.11.49",
"version": "1.11.50",
"private": true,
"type": "module",
"imports": {

View File

@@ -0,0 +1,55 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import {
getBlockPlainTextLength,
getInArticleAdAfterBlockIds,
getInArticleAdTargetRatios,
isInArticleAdCandidateBlock
} from '../lib/content-in-article-ad.js'
/**
* 테스트용 문단 블록을 만든다.
* @param {number} index - 블록 순서
* @param {string} text - 본문 텍스트
* @returns {Object} 문단 블록
*/
const createParagraphBlock = (index, text) => ({
id: `paragraph-${index}`,
type: 'paragraph',
text
})
test('블록 텍스트 길이는 배열과 문자열의 공백을 제외해 계산한다', () => {
assert.equal(getBlockPlainTextLength({ text: '가 나 다' }), 3)
assert.equal(getBlockPlainTextLength({ text: ['첫 줄', '둘째 줄'] }), 5)
})
test('인아티클 광고 목표 비율은 본문 길이 기준으로 반환한다', () => {
assert.deepEqual(getInArticleAdTargetRatios(1999), [])
assert.deepEqual(getInArticleAdTargetRatios(2000), [0.4])
assert.deepEqual(getInArticleAdTargetRatios(6000), [0.35, 0.7])
})
test('후보 블록은 충분한 길이의 문단만 허용한다', () => {
assert.equal(isInArticleAdCandidateBlock(createParagraphBlock(1, '충분히 긴 문단입니다'.repeat(2))), true)
assert.equal(isInArticleAdCandidateBlock(createParagraphBlock(2, '짧음')), false)
assert.equal(isInArticleAdCandidateBlock({ id: 'heading', type: 'heading', text: '충분히 긴 제목입니다' }), false)
})
test('짧은 본문에는 인아티클 광고 삽입 위치를 만들지 않는다', () => {
const blocks = Array.from({ length: 12 }, (_, index) => createParagraphBlock(index, '짧은 문단입니다'))
assert.deepEqual(getInArticleAdAfterBlockIds(blocks), [])
})
test('긴 본문은 중앙 근처 문단 하나를 광고 삽입 위치로 선택한다', () => {
const blocks = Array.from({ length: 12 }, (_, index) => createParagraphBlock(index, '가'.repeat(180)))
assert.deepEqual(getInArticleAdAfterBlockIds(blocks), ['paragraph-4'])
})
test('매우 긴 본문은 충분한 간격을 두고 두 위치를 선택한다', () => {
const blocks = Array.from({ length: 24 }, (_, index) => createParagraphBlock(index, '가'.repeat(260)))
assert.deepEqual(getInArticleAdAfterBlockIds(blocks), ['paragraph-8', 'paragraph-16'])
})