From 1f685245488cc8afa94a355d9f0ce32757ba2608 Mon Sep 17 00:00:00 2001 From: zenn Date: Fri, 24 Jul 2026 14:23:46 +0900 Subject: [PATCH] =?UTF-8?q?=EC=9D=B8=EC=95=84=ED=8B=B0=ED=81=B4=20?= =?UTF-8?q?=EA=B4=91=EA=B3=A0=20=EC=9C=84=EC=B9=98=20=EA=B3=84=EC=82=B0=20?= =?UTF-8?q?=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../content/ContentMarkdownRenderer.vue | 97 +--------------- docs/map.md | 2 + docs/update.md | 6 + lib/content-in-article-ad.js | 104 ++++++++++++++++++ package-lock.json | 4 +- package.json | 2 +- tests/content-in-article-ad.test.js | 55 +++++++++ 7 files changed, 172 insertions(+), 98 deletions(-) create mode 100644 lib/content-in-article-ad.js create mode 100644 tests/content-in-article-ad.test.js diff --git a/components/content/ContentMarkdownRenderer.vue b/components/content/ContentMarkdownRenderer.vue index 13aa7a6..a870265 100644 --- a/components/content/ContentMarkdownRenderer.vue +++ b/components/content/ContentMarkdownRenderer.vue @@ -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 = '' 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>} 갤러리 이미지 자연 비율 */ @@ -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(() => { diff --git a/docs/map.md b/docs/map.md index ea3e8e6..195973c 100644 --- a/docs/map.md +++ b/docs/map.md @@ -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 형식 단위 회귀 테스트 | diff --git a/docs/update.md b/docs/update.md index ccbe264..36c4444 100644 --- a/docs/update.md +++ b/docs/update.md @@ -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`로 분리. diff --git a/lib/content-in-article-ad.js b/lib/content-in-article-ad.js new file mode 100644 index 0000000..883a831 --- /dev/null +++ b/lib/content-in-article-ad.js @@ -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) +} diff --git a/package-lock.json b/package-lock.json index 31919b3..09c155d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index c7b885b..ee16dec 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "sori.studio", - "version": "1.11.49", + "version": "1.11.50", "private": true, "type": "module", "imports": { diff --git a/tests/content-in-article-ad.test.js b/tests/content-in-article-ad.test.js new file mode 100644 index 0000000..e1e4721 --- /dev/null +++ b/tests/content-in-article-ad.test.js @@ -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']) +})