mirror of
https://github.com/JimLiu/baoyu-skills.git
synced 2026-07-16 15:39:47 +08:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b02ceacfd9 | |||
| fdf9007e2c |
@@ -6,7 +6,7 @@
|
|||||||
},
|
},
|
||||||
"metadata": {
|
"metadata": {
|
||||||
"description": "Skills shared by Baoyu for improving daily work efficiency",
|
"description": "Skills shared by Baoyu for improving daily work efficiency",
|
||||||
"version": "1.36.0"
|
"version": "1.37.0"
|
||||||
},
|
},
|
||||||
"plugins": [
|
"plugins": [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -2,6 +2,13 @@
|
|||||||
|
|
||||||
English | [中文](./CHANGELOG.zh.md)
|
English | [中文](./CHANGELOG.zh.md)
|
||||||
|
|
||||||
|
## 1.37.0 - 2026-02-27
|
||||||
|
|
||||||
|
### Features
|
||||||
|
- `baoyu-danger-x-to-markdown`: add inline link rendering for X article content, mapping LINK/MEDIA entities to markdown links
|
||||||
|
- `baoyu-danger-x-to-markdown`: use content-based slug in output directory path for meaningful folder names
|
||||||
|
- `baoyu-danger-x-to-markdown`: add atomic media queue for blocks without direct media references
|
||||||
|
|
||||||
## 1.36.0 - 2026-02-27
|
## 1.36.0 - 2026-02-27
|
||||||
|
|
||||||
### Features
|
### Features
|
||||||
|
|||||||
@@ -2,6 +2,13 @@
|
|||||||
|
|
||||||
[English](./CHANGELOG.md) | 中文
|
[English](./CHANGELOG.md) | 中文
|
||||||
|
|
||||||
|
## 1.37.0 - 2026-02-27
|
||||||
|
|
||||||
|
### 新功能
|
||||||
|
- `baoyu-danger-x-to-markdown`:支持 X 文章内联链接渲染,将 LINK/MEDIA 实体映射为 Markdown 链接
|
||||||
|
- `baoyu-danger-x-to-markdown`:输出目录使用基于内容的 slug,生成更有意义的文件夹名称
|
||||||
|
- `baoyu-danger-x-to-markdown`:新增 atomic 媒体队列,支持无直接媒体引用的区块
|
||||||
|
|
||||||
## 1.36.0 - 2026-02-27
|
## 1.36.0 - 2026-02-27
|
||||||
|
|
||||||
### 新功能
|
### 新功能
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import fs from "node:fs";
|
|||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import readline from "node:readline";
|
import readline from "node:readline";
|
||||||
import process from "node:process";
|
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 { fetchXArticle } from "./graphql.js";
|
||||||
import { formatArticleMarkdown } from "./markdown.js";
|
import { formatArticleMarkdown } from "./markdown.js";
|
||||||
@@ -182,36 +182,32 @@ function sanitizeSlug(input: string): string {
|
|||||||
.slice(0, 120);
|
.slice(0, 120);
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatBackupTimestamp(date: Date = new Date()): string {
|
function extractContentSlug(markdown: string): string {
|
||||||
const pad2 = (n: number) => String(n).padStart(2, "0");
|
const headingMatch = markdown.match(/^#\s+(.+)$/m);
|
||||||
return `${date.getFullYear()}${pad2(date.getMonth() + 1)}${pad2(date.getDate())}-${pad2(date.getHours())}${pad2(
|
if (headingMatch?.[1]) {
|
||||||
date.getMinutes()
|
return sanitizeSlug(headingMatch[1].slice(0, 60)).toLowerCase();
|
||||||
)}${pad2(date.getSeconds())}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function backupDirIfExists(dir: string, log: (message: string) => void): Promise<void> {
|
|
||||||
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 ?? "")}`
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
const lines = markdown.split("\n");
|
||||||
|
let inFrontmatter = false;
|
||||||
function resolveDefaultOutputDir(slug: string): string {
|
for (const line of lines) {
|
||||||
return path.resolve(process.cwd(), "x-to-markdown", slug);
|
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(
|
async function resolveOutputPath(
|
||||||
normalizedUrl: string,
|
normalizedUrl: string,
|
||||||
kind: "tweet" | "article",
|
kind: "tweet" | "article",
|
||||||
argsOutput: string | null,
|
argsOutput: string | null,
|
||||||
|
contentSlug: string,
|
||||||
log: (message: string) => void
|
log: (message: string) => void
|
||||||
): Promise<{ outputDir: string; markdownPath: string; slug: string }> {
|
): Promise<{ outputDir: string; markdownPath: string; slug: string }> {
|
||||||
const articleId = kind === "article" ? parseArticleId(normalizedUrl) : null;
|
const articleId = kind === "article" ? parseArticleId(normalizedUrl) : null;
|
||||||
@@ -222,15 +218,14 @@ async function resolveOutputPath(
|
|||||||
const idPart = articleId ?? tweetId ?? String(Date.now());
|
const idPart = articleId ?? tweetId ?? String(Date.now());
|
||||||
const slug = userSlug ?? idPart;
|
const slug = userSlug ?? idPart;
|
||||||
|
|
||||||
const defaultFileName = kind === "article" ? `${idPart}.md` : `${idPart}.md`;
|
const defaultFileName = `${idPart}.md`;
|
||||||
|
|
||||||
if (argsOutput) {
|
if (argsOutput) {
|
||||||
const wantsDir = argsOutput.endsWith("/") || argsOutput.endsWith("\\");
|
const wantsDir = argsOutput.endsWith("/") || argsOutput.endsWith("\\");
|
||||||
const resolved = path.resolve(argsOutput);
|
const resolved = path.resolve(argsOutput);
|
||||||
try {
|
try {
|
||||||
if (wantsDir || (fs.existsSync(resolved) && fs.statSync(resolved).isDirectory())) {
|
if (wantsDir || (fs.existsSync(resolved) && fs.statSync(resolved).isDirectory())) {
|
||||||
const outputDir = path.join(resolved, slug);
|
const outputDir = path.join(resolved, slug, contentSlug);
|
||||||
await backupDirIfExists(outputDir, log);
|
|
||||||
await mkdir(outputDir, { recursive: true });
|
await mkdir(outputDir, { recursive: true });
|
||||||
return { outputDir, markdownPath: path.join(outputDir, defaultFileName), slug };
|
return { outputDir, markdownPath: path.join(outputDir, defaultFileName), slug };
|
||||||
}
|
}
|
||||||
@@ -243,8 +238,7 @@ async function resolveOutputPath(
|
|||||||
return { outputDir, markdownPath: resolved, slug };
|
return { outputDir, markdownPath: resolved, slug };
|
||||||
}
|
}
|
||||||
|
|
||||||
const outputDir = resolveDefaultOutputDir(slug);
|
const outputDir = path.resolve(process.cwd(), "x-to-markdown", slug, contentSlug);
|
||||||
await backupDirIfExists(outputDir, log);
|
|
||||||
await mkdir(outputDir, { recursive: true });
|
await mkdir(outputDir, { recursive: true });
|
||||||
return { outputDir, markdownPath: path.join(outputDir, defaultFileName), slug };
|
return { outputDir, markdownPath: path.join(outputDir, defaultFileName), slug };
|
||||||
}
|
}
|
||||||
@@ -394,13 +388,15 @@ async function main(): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const kind = articleId ? ("article" as const) : ("tweet" as const);
|
const kind = articleId ? ("article" as const) : ("tweet" as const);
|
||||||
const { outputDir, markdownPath, slug } = await resolveOutputPath(normalizedUrl, kind, args.output, log);
|
|
||||||
|
|
||||||
let markdown =
|
let markdown =
|
||||||
kind === "article" && articleId
|
kind === "article" && articleId
|
||||||
? await convertArticleToMarkdown(normalizedUrl, articleId, log)
|
? await convertArticleToMarkdown(normalizedUrl, articleId, log)
|
||||||
: await tweetToMarkdown(normalizedUrl, { 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;
|
let mediaResult: LocalizeMarkdownMediaResult | null = null;
|
||||||
|
|
||||||
if (args.downloadMedia) {
|
if (args.downloadMedia) {
|
||||||
|
|||||||
@@ -117,11 +117,114 @@ function resolveEntityMediaLines(
|
|||||||
return lines;
|
return lines;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildMediaLinkMap(
|
||||||
|
entityMap: ArticleContentState["entityMap"] | undefined
|
||||||
|
): Map<number, string> {
|
||||||
|
const map = new Map<number, string>();
|
||||||
|
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<number, string>
|
||||||
|
): 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>
|
||||||
|
): 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(
|
function renderContentBlocks(
|
||||||
blocks: ArticleBlock[],
|
blocks: ArticleBlock[],
|
||||||
entityMap: ArticleContentState["entityMap"] | undefined,
|
entityMap: ArticleContentState["entityMap"] | undefined,
|
||||||
mediaById: Map<string, string>,
|
mediaById: Map<string, string>,
|
||||||
usedUrls: Set<string>
|
usedUrls: Set<string>,
|
||||||
|
atomicMediaQueue: string[],
|
||||||
|
mediaLinkMap: Map<number, string>
|
||||||
): string[] {
|
): string[] {
|
||||||
const lines: string[] = [];
|
const lines: string[] = [];
|
||||||
let previousKind: "list" | "quote" | "heading" | "text" | "code" | "media" | null = null;
|
let previousKind: "list" | "quote" | "heading" | "text" | "code" | "media" | null = null;
|
||||||
@@ -157,7 +260,12 @@ function renderContentBlocks(
|
|||||||
|
|
||||||
for (const block of blocks) {
|
for (const block of blocks) {
|
||||||
const type = typeof block?.type === "string" ? block.type : "unstyled";
|
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 (type === "code-block") {
|
||||||
if (!inCodeBlock) {
|
if (!inCodeBlock) {
|
||||||
@@ -185,6 +293,12 @@ function renderContentBlocks(
|
|||||||
const mediaLines = collectMediaLines(block);
|
const mediaLines = collectMediaLines(block);
|
||||||
if (mediaLines.length > 0) {
|
if (mediaLines.length > 0) {
|
||||||
pushBlock(mediaLines, "media");
|
pushBlock(mediaLines, "media");
|
||||||
|
} else if (atomicMediaQueue.length > 0) {
|
||||||
|
const url = atomicMediaQueue.shift()!;
|
||||||
|
if (!usedUrls.has(url)) {
|
||||||
|
usedUrls.add(url);
|
||||||
|
pushBlock([``], "media");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -243,11 +357,6 @@ function renderContentBlocks(
|
|||||||
pushBlock([text], "text");
|
pushBlock([text], "text");
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
const trailingMediaLines = collectMediaLines(block);
|
|
||||||
if (trailingMediaLines.length > 0) {
|
|
||||||
pushBlock(trailingMediaLines, "media");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (inCodeBlock) {
|
if (inCodeBlock) {
|
||||||
@@ -284,7 +393,9 @@ export function formatArticleMarkdown(article: unknown): FormatArticleResult {
|
|||||||
const blocks = candidate.content_state?.blocks;
|
const blocks = candidate.content_state?.blocks;
|
||||||
const entityMap = candidate.content_state?.entityMap;
|
const entityMap = candidate.content_state?.entityMap;
|
||||||
if (Array.isArray(blocks) && blocks.length > 0) {
|
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 (rendered.length > 0) {
|
||||||
if (lines.length > 0) lines.push("");
|
if (lines.length > 0) lines.push("");
|
||||||
lines.push(...rendered);
|
lines.push(...rendered);
|
||||||
|
|||||||
Reference in New Issue
Block a user