From fdf9007e2ce064b74405bae055a3f34804013a53 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jim=20Liu=20=E5=AE=9D=E7=8E=89?= Date: Fri, 27 Feb 2026 18:28:34 -0600 Subject: [PATCH] feat(baoyu-danger-x-to-markdown): improve article rendering with inline links and content-based output paths --- .../scripts/main.ts | 56 ++++---- .../scripts/markdown.ts | 127 ++++++++++++++++-- 2 files changed, 145 insertions(+), 38 deletions(-) diff --git a/skills/baoyu-danger-x-to-markdown/scripts/main.ts b/skills/baoyu-danger-x-to-markdown/scripts/main.ts index 2c8f8a5..5667093 100644 --- a/skills/baoyu-danger-x-to-markdown/scripts/main.ts +++ b/skills/baoyu-danger-x-to-markdown/scripts/main.ts @@ -2,7 +2,7 @@ import fs from "node:fs"; import path from "node:path"; import readline from "node:readline"; import process from "node:process"; -import { mkdir, readFile, rename, writeFile } from "node:fs/promises"; +import { mkdir, readFile, writeFile } from "node:fs/promises"; import { fetchXArticle } from "./graphql.js"; import { formatArticleMarkdown } from "./markdown.js"; @@ -182,36 +182,32 @@ function sanitizeSlug(input: string): string { .slice(0, 120); } -function formatBackupTimestamp(date: Date = new Date()): string { - const pad2 = (n: number) => String(n).padStart(2, "0"); - return `${date.getFullYear()}${pad2(date.getMonth() + 1)}${pad2(date.getDate())}-${pad2(date.getHours())}${pad2( - date.getMinutes() - )}${pad2(date.getSeconds())}`; -} - -async function backupDirIfExists(dir: string, log: (message: string) => void): Promise { - try { - if (!fs.existsSync(dir)) return; - const stat = fs.statSync(dir); - if (!stat.isDirectory()) return; - const backup = `${dir}-backup-${formatBackupTimestamp()}`; - await rename(dir, backup); - log(`[x-to-markdown] Existing directory moved to: ${backup}`); - } catch (error) { - throw new Error( - `Failed to backup existing directory (${dir}): ${error instanceof Error ? error.message : String(error ?? "")}` - ); +function extractContentSlug(markdown: string): string { + const headingMatch = markdown.match(/^#\s+(.+)$/m); + if (headingMatch?.[1]) { + return sanitizeSlug(headingMatch[1].slice(0, 60)).toLowerCase(); } -} - -function resolveDefaultOutputDir(slug: string): string { - return path.resolve(process.cwd(), "x-to-markdown", slug); + const lines = markdown.split("\n"); + let inFrontmatter = false; + for (const line of lines) { + if (line === "---") { + inFrontmatter = !inFrontmatter; + continue; + } + if (inFrontmatter) continue; + const trimmed = line.trim(); + if (trimmed) { + return sanitizeSlug(trimmed.slice(0, 60)).toLowerCase(); + } + } + return "untitled"; } async function resolveOutputPath( normalizedUrl: string, kind: "tweet" | "article", argsOutput: string | null, + contentSlug: string, log: (message: string) => void ): Promise<{ outputDir: string; markdownPath: string; slug: string }> { const articleId = kind === "article" ? parseArticleId(normalizedUrl) : null; @@ -222,15 +218,14 @@ async function resolveOutputPath( const idPart = articleId ?? tweetId ?? String(Date.now()); const slug = userSlug ?? idPart; - const defaultFileName = kind === "article" ? `${idPart}.md` : `${idPart}.md`; + const defaultFileName = `${idPart}.md`; if (argsOutput) { const wantsDir = argsOutput.endsWith("/") || argsOutput.endsWith("\\"); const resolved = path.resolve(argsOutput); try { if (wantsDir || (fs.existsSync(resolved) && fs.statSync(resolved).isDirectory())) { - const outputDir = path.join(resolved, slug); - await backupDirIfExists(outputDir, log); + const outputDir = path.join(resolved, slug, contentSlug); await mkdir(outputDir, { recursive: true }); return { outputDir, markdownPath: path.join(outputDir, defaultFileName), slug }; } @@ -243,8 +238,7 @@ async function resolveOutputPath( return { outputDir, markdownPath: resolved, slug }; } - const outputDir = resolveDefaultOutputDir(slug); - await backupDirIfExists(outputDir, log); + const outputDir = path.resolve(process.cwd(), "x-to-markdown", slug, contentSlug); await mkdir(outputDir, { recursive: true }); return { outputDir, markdownPath: path.join(outputDir, defaultFileName), slug }; } @@ -394,13 +388,15 @@ async function main(): Promise { } const kind = articleId ? ("article" as const) : ("tweet" as const); - const { outputDir, markdownPath, slug } = await resolveOutputPath(normalizedUrl, kind, args.output, log); let markdown = kind === "article" && articleId ? await convertArticleToMarkdown(normalizedUrl, articleId, log) : await tweetToMarkdown(normalizedUrl, { log }); + const contentSlug = extractContentSlug(markdown); + const { outputDir, markdownPath, slug } = await resolveOutputPath(normalizedUrl, kind, args.output, contentSlug, log); + let mediaResult: LocalizeMarkdownMediaResult | null = null; if (args.downloadMedia) { diff --git a/skills/baoyu-danger-x-to-markdown/scripts/markdown.ts b/skills/baoyu-danger-x-to-markdown/scripts/markdown.ts index 794dc14..644f875 100644 --- a/skills/baoyu-danger-x-to-markdown/scripts/markdown.ts +++ b/skills/baoyu-danger-x-to-markdown/scripts/markdown.ts @@ -117,11 +117,114 @@ function resolveEntityMediaLines( return lines; } +function buildMediaLinkMap( + entityMap: ArticleContentState["entityMap"] | undefined +): Map { + const map = new Map(); + if (!entityMap) return map; + + const mediaEntries: { idx: number; key: number }[] = []; + const linkEntries: { key: number; url: string }[] = []; + + for (const [idx, entry] of Object.entries(entityMap)) { + const value = entry?.value; + if (!value) continue; + const key = parseInt(entry?.key ?? "", 10); + if (isNaN(key)) continue; + + if (value.type === "MEDIA" || value.type === "IMAGE") { + mediaEntries.push({ idx: Number(idx), key }); + } else if (value.type === "LINK" && typeof value.data?.url === "string") { + linkEntries.push({ key, url: value.data.url }); + } + } + + if (mediaEntries.length === 0 || linkEntries.length === 0) return map; + + mediaEntries.sort((a, b) => a.key - b.key); + linkEntries.sort((a, b) => a.key - b.key); + + const pool = [...linkEntries]; + for (const media of mediaEntries) { + if (pool.length === 0) break; + let linkIdx = pool.findIndex((l) => l.key > media.key); + if (linkIdx === -1) linkIdx = 0; + const link = pool.splice(linkIdx, 1)[0]!; + map.set(media.idx, link.url); + } + + return map; +} + +function renderInlineLinks( + text: string, + entityRanges: Array<{ key?: number; offset?: number; length?: number }>, + entityMap: ArticleContentState["entityMap"] | undefined, + mediaLinkMap: Map +): string { + if (!entityMap || entityRanges.length === 0) return text; + + const valid = entityRanges.filter( + (r) => + typeof r.key === "number" && + typeof r.offset === "number" && + typeof r.length === "number" && + r.length > 0 + ); + if (valid.length === 0) return text; + + const sorted = [...valid].sort((a, b) => (b.offset ?? 0) - (a.offset ?? 0)); + + let result = text; + for (const range of sorted) { + const offset = range.offset!; + const length = range.length!; + const key = range.key!; + + const entry = entityMap[String(key)]; + const value = entry?.value; + if (!value) continue; + + let url: string | undefined; + if (value.type === "LINK" && typeof value.data?.url === "string") { + url = value.data.url; + } else if (value.type === "MEDIA" || value.type === "IMAGE") { + url = mediaLinkMap.get(key); + } + + if (!url) continue; + + const linkText = result.slice(offset, offset + length); + result = + result.slice(0, offset) + + `[${linkText}](${url})` + + result.slice(offset + length); + } + + return result; +} + +function buildAtomicMediaQueue( + article: ArticleEntity, + usedUrls: Set +): string[] { + const queue: string[] = []; + for (const entity of article.media_entities ?? []) { + const url = resolveMediaUrl(entity?.media_info); + if (url && !usedUrls.has(url)) { + queue.push(url); + } + } + return queue; +} + function renderContentBlocks( blocks: ArticleBlock[], entityMap: ArticleContentState["entityMap"] | undefined, mediaById: Map, - usedUrls: Set + usedUrls: Set, + atomicMediaQueue: string[], + mediaLinkMap: Map ): string[] { const lines: string[] = []; let previousKind: "list" | "quote" | "heading" | "text" | "code" | "media" | null = null; @@ -157,7 +260,12 @@ function renderContentBlocks( for (const block of blocks) { const type = typeof block?.type === "string" ? block.type : "unstyled"; - const text = typeof block?.text === "string" ? block.text : ""; + const rawText = typeof block?.text === "string" ? block.text : ""; + const ranges = Array.isArray(block.entityRanges) ? block.entityRanges : []; + const text = + type !== "atomic" && type !== "code-block" + ? renderInlineLinks(rawText, ranges, entityMap, mediaLinkMap) + : rawText; if (type === "code-block") { if (!inCodeBlock) { @@ -185,6 +293,12 @@ function renderContentBlocks( const mediaLines = collectMediaLines(block); if (mediaLines.length > 0) { pushBlock(mediaLines, "media"); + } else if (atomicMediaQueue.length > 0) { + const url = atomicMediaQueue.shift()!; + if (!usedUrls.has(url)) { + usedUrls.add(url); + pushBlock([`![](${url})`], "media"); + } } continue; } @@ -243,11 +357,6 @@ function renderContentBlocks( pushBlock([text], "text"); break; } - - const trailingMediaLines = collectMediaLines(block); - if (trailingMediaLines.length > 0) { - pushBlock(trailingMediaLines, "media"); - } } if (inCodeBlock) { @@ -284,7 +393,9 @@ export function formatArticleMarkdown(article: unknown): FormatArticleResult { const blocks = candidate.content_state?.blocks; const entityMap = candidate.content_state?.entityMap; if (Array.isArray(blocks) && blocks.length > 0) { - const rendered = renderContentBlocks(blocks, entityMap, mediaById, usedUrls); + const atomicMediaQueue = buildAtomicMediaQueue(candidate, usedUrls); + const mediaLinkMap = buildMediaLinkMap(entityMap); + const rendered = renderContentBlocks(blocks, entityMap, mediaById, usedUrls, atomicMediaQueue, mediaLinkMap); if (rendered.length > 0) { if (lines.length > 0) lines.push(""); lines.push(...rendered);