feat(magazine): improve brief extraction with heading banners and cleaner content
- Rewrite extractMagazineSections to robustly parse ## and ### headings with any emoji (not just a hardcoded list) - Strip [[podcast:...]], [[film:...]] and other content tags from magazine text - Strip "For deeper coverage" and "Sources" sections from magazine content - Use heading titles as banner dividers instead of repeating them on every tile - Add group field to MagazineSection for heading-based grouping - Strip ** markdown from both titles and content - Enlarge "In response to" headline text to text-2xl for editorial prominence Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
cddfe93c5c
commit
7f8dc72dd7
@@ -18,6 +18,8 @@ export interface MagazineSection {
|
||||
author?: string
|
||||
/** Optional link to open in iframe */
|
||||
url?: string
|
||||
/** Heading group this section belongs to (for banner display) */
|
||||
group?: string
|
||||
}
|
||||
|
||||
const panelOpen = ref(false)
|
||||
@@ -120,9 +122,10 @@ function addSection(
|
||||
title: string,
|
||||
content: string,
|
||||
seen: Set<string>,
|
||||
group?: string,
|
||||
): void {
|
||||
const t = title.trim().replace(/[\p{Emoji_Presentation}\p{Extended_Pictographic}]\s*/gu, '').replace(/^#+\s*/, '').replace(/\s+/g, ' ').slice(0, 150)
|
||||
const c = content.trim().replace(/[\p{Emoji_Presentation}\p{Extended_Pictographic}]\s*/gu, '').replace(/\n{2,}/g, '\n\n').slice(0, MAGAZINE_CONTENT_MAX)
|
||||
const t = title.trim().replace(/[\p{Emoji_Presentation}\p{Extended_Pictographic}]\s*/gu, '').replace(/\*\*/g, '').replace(/^#+\s*/, '').replace(/\s+/g, ' ').slice(0, 150)
|
||||
const c = content.trim().replace(/[\p{Emoji_Presentation}\p{Extended_Pictographic}]\s*/gu, '').replace(/\*\*/g, '').replace(/\n{2,}/g, '\n\n').slice(0, MAGAZINE_CONTENT_MAX)
|
||||
if (t.length < 2 || c.length < 15) return
|
||||
const key = `${t.slice(0, 50)}`
|
||||
if (seen.has(key)) return
|
||||
@@ -140,77 +143,106 @@ function addSection(
|
||||
url: extractUrlFromText(content),
|
||||
author: extractAuthorFromText(content),
|
||||
imageUrl,
|
||||
group,
|
||||
})
|
||||
}
|
||||
|
||||
/** Extract magazine sections comprehensively: ## headings, **camp** blocks, bullets, intro */
|
||||
function cleanMagazineContent(raw: string): string {
|
||||
return raw
|
||||
.replace(/\[\[(?:podcast|film|song|book|tvshow|film_ext|song_ext):[^\]]*\]\]/g, '') // strip content tags
|
||||
.replace(/^\s*\n---\s*\n?/g, '') // strip --- separators
|
||||
.replace(/\n---\s*$/g, '')
|
||||
.replace(/\n{3,}/g, '\n\n')
|
||||
.trim()
|
||||
}
|
||||
|
||||
function extractMagazineSections(text: string): MagazineSection[] {
|
||||
const sections: MagazineSection[] = []
|
||||
const seen = new Set<string>()
|
||||
const blockContents = new Set<string>() // avoid duplicate bullets already in ## blocks
|
||||
|
||||
// 1. ## or ### Heading blocks: full content until next heading or **Section**
|
||||
const headingRe = /^#{2,3}\s*[^\n]*?(?:⚡|🔥|📌|✨|📺|⭐|🔄|🎬|🎭|🎵|🎧|📖|💡|🔍|🌍|💭|🧠|🔬|📊|🏆|📝)?\s*(.+?)\n\n([\s\S]+?)(?=\n#{2,3}\s|\n\*\*[^*]+\*\*[🟠🔴🟢]?|\n\nFor deeper|\n\nThis is being|\n\n---|\n\n\*\*TL;DR|\z)/gim
|
||||
// Strip sources/references and "For deeper" sections at the end
|
||||
let cleanedText = text
|
||||
.replace(/\n+(?:Sources|References|Links):?\s*\n[\s\S]*$/i, '')
|
||||
.replace(/\n+For deeper[^:]*:[\s\S]*$/im, '')
|
||||
|
||||
// 1. Find all ## or ### headings and extract content between them
|
||||
const headingPositions: { title: string; start: number; contentStart: number }[] = []
|
||||
const headingLineRe = /^#{2,3}\s+(.+)$/gm
|
||||
let m: RegExpExecArray | null
|
||||
while ((m = headingRe.exec(text)) !== null) {
|
||||
const title = m[1].trim().replace(/[\p{Emoji_Presentation}\p{Extended_Pictographic}]\s*/gu, '').split(':')[0].trim()
|
||||
const content = m[2].trim()
|
||||
if (content.length > 20) {
|
||||
blockContents.add(content)
|
||||
addSection(sections, title, content, seen)
|
||||
while ((m = headingLineRe.exec(cleanedText)) !== null) {
|
||||
const rawTitle = m[1].trim()
|
||||
const title = rawTitle
|
||||
.replace(/[\p{Emoji_Presentation}\p{Extended_Pictographic}]\uFE0F?\s*/gu, '')
|
||||
.replace(/^[#*_\s-]+/, '')
|
||||
.trim()
|
||||
if (title.length > 1) {
|
||||
headingPositions.push({ title, start: m.index, contentStart: m.index + m[0].length })
|
||||
}
|
||||
}
|
||||
|
||||
// For each heading, extract individual bullet points as separate sections
|
||||
for (let i = 0; i < headingPositions.length; i++) {
|
||||
const hp = headingPositions[i]
|
||||
const nextStart = i + 1 < headingPositions.length ? headingPositions[i + 1].start : cleanedText.length
|
||||
const rawContent = cleanMagazineContent(cleanedText.slice(hp.contentStart, nextStart))
|
||||
if (rawContent.length < 20) continue
|
||||
|
||||
// Split bullets within this heading block
|
||||
const bullets = rawContent
|
||||
.split(/\n\s*[-•]\s+/)
|
||||
.map(s => s.replace(/^[-•]\s*/, '').trim())
|
||||
.filter(s => s.length > 10)
|
||||
|
||||
if (bullets.length >= 2) {
|
||||
// Multiple bullets: each bullet becomes its own section under this heading group
|
||||
for (const bullet of bullets) {
|
||||
const cleaned = cleanMagazineContent(bullet)
|
||||
if (cleaned.length < 15) continue
|
||||
// Extract bold title from bullet if present: **Title**: content or **Title** — content
|
||||
const boldMatch = /^\*\*([^*]+)\*\*\s*[:\u2014\u2013–]\s*(.+)/s.exec(cleaned)
|
||||
if (boldMatch) {
|
||||
addSection(sections, boldMatch[1].trim(), boldMatch[2].trim(), seen, hp.title)
|
||||
} else {
|
||||
// No bold title — use first sentence as title, full text as content
|
||||
const sentenceMatch = /^([^.!?]{10,80}[.!?])/.exec(cleaned)
|
||||
const bulletTitle = sentenceMatch ? sentenceMatch[1] : cleaned.slice(0, 60)
|
||||
addSection(sections, bulletTitle, cleaned, seen, hp.title)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Single block content — add as one section
|
||||
addSection(sections, hp.title, rawContent, seen)
|
||||
}
|
||||
}
|
||||
|
||||
// 2. **Pro/ Anti camp** blocks with their bullets
|
||||
const campRe = /\*\*([^*]+(?:camp| side| view)[^*]*)\*\*[🟠🔴🟢]?\s*\n([\s\S]+?)(?=\n\*\*[^*]+(?:camp| side)[^*]*\*\*|\n#{2,3}\s|\n\nThis is|\n\nFor deeper|\z)/gim
|
||||
while ((m = campRe.exec(text)) !== null) {
|
||||
const campRe = /\*\*([^*]+(?:camp| side| view)[^*]*)\*\*[🟠🔴🟢]?\s*\n([\s\S]+?)(?=\n\*\*[^*]+(?:camp| side)[^*]*\*\*|\n#{2,3}\s|\n\nThis is|\n\nFor deeper|$)/gim
|
||||
while ((m = campRe.exec(cleanedText)) !== null) {
|
||||
const title = m[1].trim()
|
||||
const content = m[2].trim()
|
||||
const content = cleanMagazineContent(m[2])
|
||||
if (content.length > 15) addSection(sections, title, content, seen)
|
||||
}
|
||||
|
||||
// 3. Bullets: - **Title**: Content (skip if already in a ## or ### block)
|
||||
const bulletRe = /^\s*[-•]\s*\*\*([^*]+)\*\*\s*[:\u2014\u2013–]\s*(.+?)(?=\n\s*[-•]\s*\*\*|\n\s*[-•]\s+\w|\n\n#{2,3}\s|\n\*\*[^*]+\*\*[🟠🔴]?|\z)/gms
|
||||
while ((m = bulletRe.exec(text)) !== null) {
|
||||
const raw = m[2].trim().replace(/\n+/g, ' ')
|
||||
if (raw.length < 15) continue
|
||||
const inBlock = [...blockContents].some((b) => b.includes(raw.slice(0, 100)))
|
||||
if (!inBlock) addSection(sections, m[1].trim(), raw, seen)
|
||||
}
|
||||
|
||||
// 4. - **Name** (Role) description — attributed quotes
|
||||
const attrRe = /^\s*[-•]\s*\*\*([^*]+)\*\*\s*\(([^)]+)\)\s+([^-\n].+?)(?=\n\s*[-•]|\n\*\*|\n#{2,3}\s|\z)/gms
|
||||
while ((m = attrRe.exec(text)) !== null) {
|
||||
const content = m[3].trim().replace(/\n+/g, ' ')
|
||||
if (content.length > 20) addSection(sections, `${m[1].trim()} (${m[2].trim()})`, content, seen)
|
||||
}
|
||||
|
||||
// 5. Intro paragraph before first ## / ### or ---
|
||||
const introMatch = /^([\s\S]+?)(?=\n#{2,3}\s|\n---\s*\n|\n-\s+\*\*[^*]+\*\*\s*[:\u2014])/m.exec(text)
|
||||
if (introMatch) {
|
||||
const intro = introMatch[1].trim().replace(/^[#*_\s-]+/gm, '').trim()
|
||||
if (intro.length > 60 && !seen.has('Summary')) {
|
||||
// 3. Intro paragraph before first heading or ---
|
||||
const firstHeadingIdx = headingPositions.length > 0 ? headingPositions[0].start : -1
|
||||
const introEnd = firstHeadingIdx > 0 ? firstHeadingIdx : cleanedText.indexOf('\n---\n')
|
||||
if (introEnd > 0) {
|
||||
const intro = cleanedText.slice(0, introEnd).trim()
|
||||
.replace(/^[#*_\s-]+/gm, '').trim()
|
||||
if (intro.length > 30 && !seen.has('Summary')) {
|
||||
addSection(sections, 'Summary', intro, seen)
|
||||
}
|
||||
}
|
||||
|
||||
// 6. "This is being called..." / closing paragraph
|
||||
const closingMatch = /(This is being called[^.]+\.[^"]*"[^"]+"[^.]*\.)/i.exec(text)
|
||||
// 4. "This is being called..." / closing paragraph
|
||||
const closingMatch = /(This is being called[^.]+\.[^"]*"[^"]+"[^.]*\.)/i.exec(cleanedText)
|
||||
if (closingMatch && !seen.has('Key')) {
|
||||
addSection(sections, 'Key takeaway', closingMatch[1].trim(), seen)
|
||||
}
|
||||
|
||||
// 7. "For deeper analysis" / podcast or further reading block
|
||||
const deeperMatch = /(?:For deeper analysis[^:]*:[\s\S]+?)(?=\n\z)/im.exec(text)
|
||||
if (deeperMatch) {
|
||||
const block = deeperMatch[1].trim()
|
||||
if (block.length > 30 && !seen.has('Further')) {
|
||||
addSection(sections, 'Further reading', block, seen)
|
||||
}
|
||||
}
|
||||
|
||||
// Preserve document order: Summary first, then ## blocks order, then camps, then key/further
|
||||
const order = ['Summary', 'Key takeaway', 'Further reading']
|
||||
// Preserve document order: Summary first, then heading content in order, then key
|
||||
const order = ['Summary', 'Key takeaway']
|
||||
sections.sort((a, b) => {
|
||||
const ai = order.indexOf(a.title)
|
||||
const bi = order.indexOf(b.title)
|
||||
|
||||
Reference in New Issue
Block a user