인아티클 광고 위치 계산 분리
This commit is contained in:
@@ -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(() => {
|
||||
|
||||
@@ -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 형식 단위 회귀 테스트 |
|
||||
|
||||
@@ -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`로 분리.
|
||||
|
||||
104
lib/content-in-article-ad.js
Normal file
104
lib/content-in-article-ad.js
Normal 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
4
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "sori.studio",
|
||||
"version": "1.11.49",
|
||||
"version": "1.11.50",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"imports": {
|
||||
|
||||
55
tests/content-in-article-ad.test.js
Normal file
55
tests/content-in-article-ad.test.js
Normal 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'])
|
||||
})
|
||||
Reference in New Issue
Block a user