mirror of
https://github.com/JimLiu/baoyu-skills.git
synced 2026-07-13 22:29:48 +08:00
Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a2eecec26f | |||
| 903e7ad79d | |||
| 13707fa2cf | |||
| b56e503b16 |
@@ -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.37.1"
|
"version": "1.39.0"
|
||||||
},
|
},
|
||||||
"plugins": [
|
"plugins": [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -2,6 +2,22 @@
|
|||||||
|
|
||||||
English | [中文](./CHANGELOG.zh.md)
|
English | [中文](./CHANGELOG.zh.md)
|
||||||
|
|
||||||
|
## 1.39.0 - 2026-02-28
|
||||||
|
|
||||||
|
### Features
|
||||||
|
- `baoyu-markdown-to-html`: add red theme (traditional calligraphy style with red-gold palette and serif typography) and orange theme (warm modern style with rounded corners and relaxed line height)
|
||||||
|
|
||||||
|
## 1.38.0 - 2026-02-28
|
||||||
|
|
||||||
|
### Features
|
||||||
|
- `baoyu-danger-x-to-markdown`: render embedded tweets in articles as blockquotes with author info and text summary
|
||||||
|
- `baoyu-danger-x-to-markdown`: reuse existing markdown when `--download-media` targets already-converted URLs
|
||||||
|
- `baoyu-danger-x-to-markdown`: upgrade Twitter image downloads to 4096x4096 high resolution
|
||||||
|
|
||||||
|
### Fixes
|
||||||
|
- `baoyu-danger-x-to-markdown`: improve entity resolution with logical key lookup for reliable media and link mapping
|
||||||
|
- `baoyu-danger-x-to-markdown`: support trailing media for all block types (headings, lists, blockquotes)
|
||||||
|
|
||||||
## 1.37.1 - 2026-02-27
|
## 1.37.1 - 2026-02-27
|
||||||
|
|
||||||
### Fixes
|
### Fixes
|
||||||
|
|||||||
@@ -2,6 +2,22 @@
|
|||||||
|
|
||||||
[English](./CHANGELOG.md) | 中文
|
[English](./CHANGELOG.md) | 中文
|
||||||
|
|
||||||
|
## 1.39.0 - 2026-02-28
|
||||||
|
|
||||||
|
### 新功能
|
||||||
|
- `baoyu-markdown-to-html`:新增红色主题(红金配色、宋体排版、传统书法风格)和橙色主题(暖色调现代风、圆角装饰、宽松行距)
|
||||||
|
|
||||||
|
## 1.38.0 - 2026-02-28
|
||||||
|
|
||||||
|
### 新功能
|
||||||
|
- `baoyu-danger-x-to-markdown`:支持文章内嵌推文渲染,以引用块形式显示作者信息和推文摘要
|
||||||
|
- `baoyu-danger-x-to-markdown`:`--download-media` 复用已转换的 Markdown 文件,跳过重复抓取
|
||||||
|
- `baoyu-danger-x-to-markdown`:推特图片下载升级至 4096x4096 高分辨率
|
||||||
|
|
||||||
|
### 修复
|
||||||
|
- `baoyu-danger-x-to-markdown`:改进实体解析逻辑,通过逻辑键查找提升媒体和链接映射准确性
|
||||||
|
- `baoyu-danger-x-to-markdown`:所有区块类型(标题、列表、引用块)支持尾随媒体展示
|
||||||
|
|
||||||
## 1.37.1 - 2026-02-27
|
## 1.37.1 - 2026-02-27
|
||||||
|
|
||||||
### 修复
|
### 修复
|
||||||
|
|||||||
@@ -170,7 +170,7 @@ coverImage: "https://pbs.twimg.com/media/example.jpg"
|
|||||||
Content...
|
Content...
|
||||||
```
|
```
|
||||||
|
|
||||||
**File structure**: `x-to-markdown/{username}/{tweet-id}.md`
|
**File structure**: `x-to-markdown/{username}/{tweet-id}/{content-slug}.md`
|
||||||
|
|
||||||
When `--download-media` is enabled:
|
When `--download-media` is enabled:
|
||||||
- Images are saved to `imgs/` next to the markdown file
|
- Images are saved to `imgs/` next to the markdown file
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ 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";
|
||||||
import { localizeMarkdownMedia, type LocalizeMarkdownMediaResult } from "./media-localizer.js";
|
import { localizeMarkdownMedia, type LocalizeMarkdownMediaResult } from "./media-localizer.js";
|
||||||
|
import { resolveReferencedTweetsFromArticle } from "./referenced-tweets.js";
|
||||||
import { hasRequiredXCookies, loadXCookies, refreshXCookies } from "./cookies.js";
|
import { hasRequiredXCookies, loadXCookies, refreshXCookies } from "./cookies.js";
|
||||||
import { resolveXToMarkdownConsentPath } from "./paths.js";
|
import { resolveXToMarkdownConsentPath } from "./paths.js";
|
||||||
import { tweetToMarkdown } from "./tweet-to-markdown.js";
|
import { tweetToMarkdown } from "./tweet-to-markdown.js";
|
||||||
@@ -203,6 +204,134 @@ function extractContentSlug(markdown: string): string {
|
|||||||
return "untitled";
|
return "untitled";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolveSlugAndId(normalizedUrl: string, kind: "tweet" | "article"): { slug: string; idPart: string } {
|
||||||
|
const articleId = kind === "article" ? parseArticleId(normalizedUrl) : null;
|
||||||
|
const tweetId = kind === "tweet" ? parseTweetId(normalizedUrl) : null;
|
||||||
|
const username = kind === "tweet" ? parseTweetUsername(normalizedUrl) : null;
|
||||||
|
|
||||||
|
const idPart = articleId ?? tweetId ?? String(Date.now());
|
||||||
|
const userSlug = username ? sanitizeSlug(username) : null;
|
||||||
|
const slug = userSlug ?? idPart;
|
||||||
|
return { slug, idPart };
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractFrontmatterUrls(markdown: string): string[] {
|
||||||
|
const match = markdown.match(/^---\n([\s\S]*?)\n---/);
|
||||||
|
if (!match?.[1]) return [];
|
||||||
|
|
||||||
|
const lines = match[1].split("\n");
|
||||||
|
const urls: string[] = [];
|
||||||
|
for (const line of lines) {
|
||||||
|
const m = line.match(/^(url|requestedUrl):\s*["']([^"']+)["']\s*$/);
|
||||||
|
if (m?.[2]) {
|
||||||
|
urls.push(m[2]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return urls;
|
||||||
|
}
|
||||||
|
|
||||||
|
function frontmatterMatchesTarget(
|
||||||
|
markdown: string,
|
||||||
|
normalizedUrl: string,
|
||||||
|
kind: "tweet" | "article"
|
||||||
|
): boolean {
|
||||||
|
const urls = extractFrontmatterUrls(markdown);
|
||||||
|
if (urls.length === 0) return false;
|
||||||
|
|
||||||
|
const targetId = kind === "article" ? parseArticleId(normalizedUrl) : parseTweetId(normalizedUrl);
|
||||||
|
if (!targetId) return false;
|
||||||
|
|
||||||
|
for (const url of urls) {
|
||||||
|
const candidateId = kind === "article" ? parseArticleId(url) : parseTweetId(url);
|
||||||
|
if (candidateId && candidateId === targetId) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function listMarkdownFiles(dirPath: string): string[] {
|
||||||
|
try {
|
||||||
|
return fs
|
||||||
|
.readdirSync(dirPath)
|
||||||
|
.filter((name) => name.toLowerCase().endsWith(".md"))
|
||||||
|
.map((name) => path.join(dirPath, name))
|
||||||
|
.sort();
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveExistingMarkdownPath(
|
||||||
|
normalizedUrl: string,
|
||||||
|
kind: "tweet" | "article",
|
||||||
|
argsOutput: string | null
|
||||||
|
): string | null {
|
||||||
|
const { slug, idPart } = resolveSlugAndId(normalizedUrl, kind);
|
||||||
|
const candidateDirs = new Set<string>();
|
||||||
|
const candidateFiles = new Set<string>();
|
||||||
|
|
||||||
|
if (argsOutput) {
|
||||||
|
const resolved = path.resolve(argsOutput);
|
||||||
|
const looksDir = argsOutput.endsWith("/") || argsOutput.endsWith("\\");
|
||||||
|
try {
|
||||||
|
if (fs.existsSync(resolved)) {
|
||||||
|
const stat = fs.statSync(resolved);
|
||||||
|
if (stat.isFile()) {
|
||||||
|
candidateFiles.add(resolved);
|
||||||
|
} else if (stat.isDirectory()) {
|
||||||
|
candidateDirs.add(path.join(resolved, slug, idPart));
|
||||||
|
candidateDirs.add(resolved);
|
||||||
|
}
|
||||||
|
} else if (looksDir) {
|
||||||
|
candidateDirs.add(path.join(resolved, slug, idPart));
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore and continue
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
candidateDirs.add(path.resolve(process.cwd(), "x-to-markdown", slug, idPart));
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const filePath of candidateFiles) {
|
||||||
|
if (!filePath.toLowerCase().endsWith(".md")) continue;
|
||||||
|
try {
|
||||||
|
const markdown = fs.readFileSync(filePath, "utf8");
|
||||||
|
if (frontmatterMatchesTarget(markdown, normalizedUrl, kind)) {
|
||||||
|
return filePath;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore and continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const dirPath of candidateDirs) {
|
||||||
|
if (!fs.existsSync(dirPath)) continue;
|
||||||
|
let stat: fs.Stats;
|
||||||
|
try {
|
||||||
|
stat = fs.statSync(dirPath);
|
||||||
|
} catch {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!stat.isDirectory()) continue;
|
||||||
|
|
||||||
|
const markdownFiles = listMarkdownFiles(dirPath);
|
||||||
|
for (const filePath of markdownFiles) {
|
||||||
|
try {
|
||||||
|
const markdown = fs.readFileSync(filePath, "utf8");
|
||||||
|
if (frontmatterMatchesTarget(markdown, normalizedUrl, kind)) {
|
||||||
|
return filePath;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore and continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
async function resolveOutputPath(
|
async function resolveOutputPath(
|
||||||
normalizedUrl: string,
|
normalizedUrl: string,
|
||||||
kind: "tweet" | "article",
|
kind: "tweet" | "article",
|
||||||
@@ -218,14 +347,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 = `${idPart}.md`;
|
const defaultFileName = `${contentSlug}.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, contentSlug);
|
const outputDir = path.join(resolved, slug, idPart);
|
||||||
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 };
|
||||||
}
|
}
|
||||||
@@ -238,7 +367,7 @@ async function resolveOutputPath(
|
|||||||
return { outputDir, markdownPath: resolved, slug };
|
return { outputDir, markdownPath: resolved, slug };
|
||||||
}
|
}
|
||||||
|
|
||||||
const outputDir = path.resolve(process.cwd(), "x-to-markdown", slug, contentSlug);
|
const outputDir = path.resolve(process.cwd(), "x-to-markdown", slug, idPart);
|
||||||
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 };
|
||||||
}
|
}
|
||||||
@@ -349,7 +478,8 @@ async function convertArticleToMarkdown(
|
|||||||
|
|
||||||
log(`[x-to-markdown] Fetching article ${articleId}...`);
|
log(`[x-to-markdown] Fetching article ${articleId}...`);
|
||||||
const article = await fetchXArticle(articleId, cookieMap, false);
|
const article = await fetchXArticle(articleId, cookieMap, false);
|
||||||
const { markdown: body, coverUrl } = formatArticleMarkdown(article);
|
const referencedTweets = await resolveReferencedTweetsFromArticle(article, cookieMap, { log });
|
||||||
|
const { markdown: body, coverUrl } = formatArticleMarkdown(article, { referencedTweets });
|
||||||
|
|
||||||
const title = typeof (article as any)?.title === "string" ? String((article as any).title).trim() : "";
|
const title = typeof (article as any)?.title === "string" ? String((article as any).title).trim() : "";
|
||||||
const meta = formatMetaMarkdown({
|
const meta = formatMetaMarkdown({
|
||||||
@@ -389,6 +519,58 @@ async function main(): Promise<void> {
|
|||||||
|
|
||||||
const kind = articleId ? ("article" as const) : ("tweet" as const);
|
const kind = articleId ? ("article" as const) : ("tweet" as const);
|
||||||
|
|
||||||
|
if (args.downloadMedia) {
|
||||||
|
const existingMarkdownPath = resolveExistingMarkdownPath(normalizedUrl, kind, args.output);
|
||||||
|
if (existingMarkdownPath) {
|
||||||
|
log(`[x-to-markdown] Reusing existing markdown: ${existingMarkdownPath}`);
|
||||||
|
const existingMarkdown = await readFile(existingMarkdownPath, "utf8");
|
||||||
|
const mediaResult = await localizeMarkdownMedia(existingMarkdown, {
|
||||||
|
markdownPath: existingMarkdownPath,
|
||||||
|
log,
|
||||||
|
});
|
||||||
|
const didLocalize =
|
||||||
|
mediaResult.downloadedImages > 0 ||
|
||||||
|
mediaResult.downloadedVideos > 0 ||
|
||||||
|
mediaResult.markdown !== existingMarkdown;
|
||||||
|
|
||||||
|
if (didLocalize) {
|
||||||
|
await writeFile(existingMarkdownPath, mediaResult.markdown, "utf8");
|
||||||
|
log(
|
||||||
|
`[x-to-markdown] Media localized: images=${mediaResult.downloadedImages}, videos=${mediaResult.downloadedVideos}`
|
||||||
|
);
|
||||||
|
log(`[x-to-markdown] Saved: ${existingMarkdownPath}`);
|
||||||
|
|
||||||
|
const { slug } = resolveSlugAndId(normalizedUrl, kind);
|
||||||
|
if (args.json) {
|
||||||
|
console.log(
|
||||||
|
JSON.stringify(
|
||||||
|
{
|
||||||
|
url: articleId ? `https://x.com/i/article/${articleId}` : normalizedUrl,
|
||||||
|
requestedUrl: normalizedUrl,
|
||||||
|
type: kind,
|
||||||
|
slug,
|
||||||
|
outputDir: path.dirname(existingMarkdownPath),
|
||||||
|
markdownPath: existingMarkdownPath,
|
||||||
|
downloadMedia: true,
|
||||||
|
downloadedImages: mediaResult.downloadedImages,
|
||||||
|
downloadedVideos: mediaResult.downloadedVideos,
|
||||||
|
imageDir: mediaResult.imageDir,
|
||||||
|
videoDir: mediaResult.videoDir,
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2
|
||||||
|
)
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
console.log(existingMarkdownPath);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
log("[x-to-markdown] Existing markdown already localized; rebuilding content to refresh placement.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let markdown =
|
let markdown =
|
||||||
kind === "article" && articleId
|
kind === "article" && articleId
|
||||||
? await convertArticleToMarkdown(normalizedUrl, articleId, log)
|
? await convertArticleToMarkdown(normalizedUrl, articleId, log)
|
||||||
|
|||||||
@@ -2,9 +2,22 @@ import type {
|
|||||||
ArticleBlock,
|
ArticleBlock,
|
||||||
ArticleContentState,
|
ArticleContentState,
|
||||||
ArticleEntity,
|
ArticleEntity,
|
||||||
|
ArticleEntityMapEntry,
|
||||||
ArticleMediaInfo,
|
ArticleMediaInfo,
|
||||||
} from "./types.js";
|
} from "./types.js";
|
||||||
|
|
||||||
|
export type ReferencedTweetInfo = {
|
||||||
|
id: string;
|
||||||
|
url: string;
|
||||||
|
authorName?: string;
|
||||||
|
authorUsername?: string;
|
||||||
|
text?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type FormatArticleOptions = {
|
||||||
|
referencedTweets?: Map<string, ReferencedTweetInfo>;
|
||||||
|
};
|
||||||
|
|
||||||
function coerceArticleEntity(value: unknown): ArticleEntity | null {
|
function coerceArticleEntity(value: unknown): ArticleEntity | null {
|
||||||
if (!value || typeof value !== "object") return null;
|
if (!value || typeof value !== "object") return null;
|
||||||
const candidate = value as ArticleEntity;
|
const candidate = value as ArticleEntity;
|
||||||
@@ -29,6 +42,73 @@ function normalizeCaption(caption?: string): string {
|
|||||||
return trimmed.replace(/\s+/g, " ");
|
return trimmed.replace(/\s+/g, " ");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function summarizeTweetText(text?: string): string {
|
||||||
|
const trimmed = text?.trim();
|
||||||
|
if (!trimmed) return "";
|
||||||
|
const normalized = trimmed
|
||||||
|
.split(/\r?\n+/)
|
||||||
|
.map((line) => line.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" ");
|
||||||
|
if (normalized.length <= 280) return normalized;
|
||||||
|
return `${normalized.slice(0, 277)}...`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildTweetUrl(tweetId?: string, username?: string): string | null {
|
||||||
|
if (!tweetId) return null;
|
||||||
|
if (username) {
|
||||||
|
return `https://x.com/${username}/status/${tweetId}`;
|
||||||
|
}
|
||||||
|
return `https://x.com/i/web/status/${tweetId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
type EntityLookup = {
|
||||||
|
byIndex: Map<number, ArticleEntityMapEntry>;
|
||||||
|
byLogicalKey: Map<number, ArticleEntityMapEntry>;
|
||||||
|
};
|
||||||
|
|
||||||
|
function buildEntityLookup(
|
||||||
|
entityMap: ArticleContentState["entityMap"] | undefined
|
||||||
|
): EntityLookup {
|
||||||
|
const lookup: EntityLookup = {
|
||||||
|
byIndex: new Map<number, ArticleEntityMapEntry>(),
|
||||||
|
byLogicalKey: new Map<number, ArticleEntityMapEntry>(),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!entityMap) return lookup;
|
||||||
|
|
||||||
|
for (const [idx, entry] of Object.entries(entityMap)) {
|
||||||
|
const idxNum = Number(idx);
|
||||||
|
if (Number.isFinite(idxNum)) {
|
||||||
|
lookup.byIndex.set(idxNum, entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
const logicalKey = parseInt(entry?.key ?? "", 10);
|
||||||
|
if (Number.isFinite(logicalKey) && !lookup.byLogicalKey.has(logicalKey)) {
|
||||||
|
lookup.byLogicalKey.set(logicalKey, entry);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return lookup;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveEntityEntry(
|
||||||
|
entityKey: number | undefined,
|
||||||
|
entityMap: ArticleContentState["entityMap"] | undefined,
|
||||||
|
lookup: EntityLookup
|
||||||
|
): ArticleEntityMapEntry | undefined {
|
||||||
|
if (entityKey === undefined) return undefined;
|
||||||
|
|
||||||
|
const byLogicalKey = lookup.byLogicalKey.get(entityKey);
|
||||||
|
if (byLogicalKey) return byLogicalKey;
|
||||||
|
|
||||||
|
const byIndex = lookup.byIndex.get(entityKey);
|
||||||
|
if (byIndex) return byIndex;
|
||||||
|
|
||||||
|
if (!entityMap) return undefined;
|
||||||
|
return entityMap[String(entityKey)];
|
||||||
|
}
|
||||||
|
|
||||||
function resolveMediaUrl(info?: ArticleMediaInfo): string | undefined {
|
function resolveMediaUrl(info?: ArticleMediaInfo): string | undefined {
|
||||||
if (!info) return undefined;
|
if (!info) return undefined;
|
||||||
if (info.original_img_url) return info.original_img_url;
|
if (info.original_img_url) return info.original_img_url;
|
||||||
@@ -79,11 +159,12 @@ function collectMediaUrls(
|
|||||||
function resolveEntityMediaLines(
|
function resolveEntityMediaLines(
|
||||||
entityKey: number | undefined,
|
entityKey: number | undefined,
|
||||||
entityMap: ArticleContentState["entityMap"] | undefined,
|
entityMap: ArticleContentState["entityMap"] | undefined,
|
||||||
|
entityLookup: EntityLookup,
|
||||||
mediaById: Map<string, string>,
|
mediaById: Map<string, string>,
|
||||||
usedUrls: Set<string>
|
usedUrls: Set<string>
|
||||||
): string[] {
|
): string[] {
|
||||||
if (entityKey === undefined || !entityMap) return [];
|
if (entityKey === undefined) return [];
|
||||||
const entry = entityMap[String(entityKey)];
|
const entry = resolveEntityEntry(entityKey, entityMap, entityLookup);
|
||||||
const value = entry?.value;
|
const value = entry?.value;
|
||||||
if (!value) return [];
|
if (!value) return [];
|
||||||
const type = value.type;
|
const type = value.type;
|
||||||
@@ -117,6 +198,45 @@ function resolveEntityMediaLines(
|
|||||||
return lines;
|
return lines;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolveEntityTweetLines(
|
||||||
|
entityKey: number | undefined,
|
||||||
|
entityMap: ArticleContentState["entityMap"] | undefined,
|
||||||
|
entityLookup: EntityLookup,
|
||||||
|
referencedTweets?: Map<string, ReferencedTweetInfo>
|
||||||
|
): string[] {
|
||||||
|
if (entityKey === undefined) return [];
|
||||||
|
const entry = resolveEntityEntry(entityKey, entityMap, entityLookup);
|
||||||
|
const value = entry?.value;
|
||||||
|
if (!value || value.type !== "TWEET") return [];
|
||||||
|
|
||||||
|
const tweetId = typeof value.data?.tweetId === "string" ? value.data.tweetId : "";
|
||||||
|
if (!tweetId) return [];
|
||||||
|
|
||||||
|
const referenced = referencedTweets?.get(tweetId);
|
||||||
|
const url =
|
||||||
|
referenced?.url ??
|
||||||
|
buildTweetUrl(tweetId, referenced?.authorUsername) ??
|
||||||
|
`https://x.com/i/web/status/${tweetId}`;
|
||||||
|
|
||||||
|
const authorText =
|
||||||
|
referenced?.authorName && referenced?.authorUsername
|
||||||
|
? `${referenced.authorName} (@${referenced.authorUsername})`
|
||||||
|
: referenced?.authorUsername
|
||||||
|
? `@${referenced.authorUsername}`
|
||||||
|
: referenced?.authorName;
|
||||||
|
|
||||||
|
const lines: string[] = [];
|
||||||
|
lines.push(`> 引用推文${authorText ? `:${authorText}` : ""}`);
|
||||||
|
|
||||||
|
const summary = summarizeTweetText(referenced?.text);
|
||||||
|
if (summary) {
|
||||||
|
lines.push(`> ${summary}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.push(`> ${url}`);
|
||||||
|
return lines;
|
||||||
|
}
|
||||||
|
|
||||||
function buildMediaLinkMap(
|
function buildMediaLinkMap(
|
||||||
entityMap: ArticleContentState["entityMap"] | undefined
|
entityMap: ArticleContentState["entityMap"] | undefined
|
||||||
): Map<number, string> {
|
): Map<number, string> {
|
||||||
@@ -151,6 +271,7 @@ function buildMediaLinkMap(
|
|||||||
if (linkIdx === -1) linkIdx = 0;
|
if (linkIdx === -1) linkIdx = 0;
|
||||||
const link = pool.splice(linkIdx, 1)[0]!;
|
const link = pool.splice(linkIdx, 1)[0]!;
|
||||||
map.set(media.idx, link.url);
|
map.set(media.idx, link.url);
|
||||||
|
map.set(media.key, link.url);
|
||||||
}
|
}
|
||||||
|
|
||||||
return map;
|
return map;
|
||||||
@@ -160,6 +281,7 @@ function renderInlineLinks(
|
|||||||
text: string,
|
text: string,
|
||||||
entityRanges: Array<{ key?: number; offset?: number; length?: number }>,
|
entityRanges: Array<{ key?: number; offset?: number; length?: number }>,
|
||||||
entityMap: ArticleContentState["entityMap"] | undefined,
|
entityMap: ArticleContentState["entityMap"] | undefined,
|
||||||
|
entityLookup: EntityLookup,
|
||||||
mediaLinkMap: Map<number, string>
|
mediaLinkMap: Map<number, string>
|
||||||
): string {
|
): string {
|
||||||
if (!entityMap || entityRanges.length === 0) return text;
|
if (!entityMap || entityRanges.length === 0) return text;
|
||||||
@@ -181,7 +303,7 @@ function renderInlineLinks(
|
|||||||
const length = range.length!;
|
const length = range.length!;
|
||||||
const key = range.key!;
|
const key = range.key!;
|
||||||
|
|
||||||
const entry = entityMap[String(key)];
|
const entry = resolveEntityEntry(key, entityMap, entityLookup);
|
||||||
const value = entry?.value;
|
const value = entry?.value;
|
||||||
if (!value) continue;
|
if (!value) continue;
|
||||||
|
|
||||||
@@ -204,27 +326,14 @@ function renderInlineLinks(
|
|||||||
return result;
|
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,
|
||||||
|
entityLookup: EntityLookup,
|
||||||
mediaById: Map<string, string>,
|
mediaById: Map<string, string>,
|
||||||
usedUrls: Set<string>,
|
usedUrls: Set<string>,
|
||||||
atomicMediaQueue: string[],
|
mediaLinkMap: Map<number, string>,
|
||||||
mediaLinkMap: Map<number, string>
|
referencedTweets?: Map<string, ReferencedTweetInfo>
|
||||||
): 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;
|
||||||
@@ -253,18 +362,54 @@ function renderContentBlocks(
|
|||||||
const mediaLines: string[] = [];
|
const mediaLines: string[] = [];
|
||||||
for (const range of ranges) {
|
for (const range of ranges) {
|
||||||
if (typeof range?.key !== "number") continue;
|
if (typeof range?.key !== "number") continue;
|
||||||
mediaLines.push(...resolveEntityMediaLines(range.key, entityMap, mediaById, usedUrls));
|
mediaLines.push(
|
||||||
|
...resolveEntityMediaLines(range.key, entityMap, entityLookup, mediaById, usedUrls)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return mediaLines;
|
return mediaLines;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const collectTweetLines = (block: ArticleBlock): string[] => {
|
||||||
|
const ranges = Array.isArray(block.entityRanges) ? block.entityRanges : [];
|
||||||
|
const tweetLines: string[] = [];
|
||||||
|
for (const range of ranges) {
|
||||||
|
if (typeof range?.key !== "number") continue;
|
||||||
|
tweetLines.push(
|
||||||
|
...resolveEntityTweetLines(range.key, entityMap, entityLookup, referencedTweets)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return tweetLines;
|
||||||
|
};
|
||||||
|
|
||||||
|
const collectLinkLines = (block: ArticleBlock): string[] => {
|
||||||
|
const ranges = Array.isArray(block.entityRanges) ? block.entityRanges : [];
|
||||||
|
const linkLines: string[] = [];
|
||||||
|
for (const range of ranges) {
|
||||||
|
if (typeof range?.key !== "number") continue;
|
||||||
|
const entry = resolveEntityEntry(range.key, entityMap, entityLookup);
|
||||||
|
const value = entry?.value;
|
||||||
|
if (value?.type !== "LINK") continue;
|
||||||
|
const url = typeof value.data?.url === "string" ? value.data.url : "";
|
||||||
|
if (url) {
|
||||||
|
linkLines.push(url);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [...new Set(linkLines)];
|
||||||
|
};
|
||||||
|
|
||||||
|
const pushTrailingMedia = (mediaLines: string[]) => {
|
||||||
|
if (mediaLines.length > 0) {
|
||||||
|
pushBlock(mediaLines, "media");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
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 rawText = typeof block?.text === "string" ? block.text : "";
|
const rawText = typeof block?.text === "string" ? block.text : "";
|
||||||
const ranges = Array.isArray(block.entityRanges) ? block.entityRanges : [];
|
const ranges = Array.isArray(block.entityRanges) ? block.entityRanges : [];
|
||||||
const text =
|
const text =
|
||||||
type !== "atomic" && type !== "code-block"
|
type !== "atomic" && type !== "code-block"
|
||||||
? renderInlineLinks(rawText, ranges, entityMap, mediaLinkMap)
|
? renderInlineLinks(rawText, ranges, entityMap, entityLookup, mediaLinkMap)
|
||||||
: rawText;
|
: rawText;
|
||||||
|
|
||||||
if (type === "code-block") {
|
if (type === "code-block") {
|
||||||
@@ -290,16 +435,22 @@ function renderContentBlocks(
|
|||||||
}
|
}
|
||||||
listKind = null;
|
listKind = null;
|
||||||
orderedIndex = 0;
|
orderedIndex = 0;
|
||||||
|
|
||||||
|
const tweetLines = collectTweetLines(block);
|
||||||
|
if (tweetLines.length > 0) {
|
||||||
|
pushBlock(tweetLines, "quote");
|
||||||
|
}
|
||||||
|
|
||||||
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");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const linkLines = collectLinkLines(block);
|
||||||
|
if (linkLines.length > 0) {
|
||||||
|
pushBlock(linkLines, "text");
|
||||||
|
}
|
||||||
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -313,6 +464,7 @@ function renderContentBlocks(
|
|||||||
listKind = "unordered";
|
listKind = "unordered";
|
||||||
orderedIndex = 0;
|
orderedIndex = 0;
|
||||||
pushBlock([`- ${text}`], "list");
|
pushBlock([`- ${text}`], "list");
|
||||||
|
pushTrailingMedia(collectMediaLines(block));
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -323,6 +475,7 @@ function renderContentBlocks(
|
|||||||
listKind = "ordered";
|
listKind = "ordered";
|
||||||
orderedIndex += 1;
|
orderedIndex += 1;
|
||||||
pushBlock([`${orderedIndex}. ${text}`], "list");
|
pushBlock([`${orderedIndex}. ${text}`], "list");
|
||||||
|
pushTrailingMedia(collectMediaLines(block));
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -332,29 +485,41 @@ function renderContentBlocks(
|
|||||||
switch (type) {
|
switch (type) {
|
||||||
case "header-one":
|
case "header-one":
|
||||||
pushBlock([`# ${text}`], "heading");
|
pushBlock([`# ${text}`], "heading");
|
||||||
|
pushTrailingMedia(collectMediaLines(block));
|
||||||
break;
|
break;
|
||||||
case "header-two":
|
case "header-two":
|
||||||
pushBlock([`## ${text}`], "heading");
|
pushBlock([`## ${text}`], "heading");
|
||||||
|
pushTrailingMedia(collectMediaLines(block));
|
||||||
break;
|
break;
|
||||||
case "header-three":
|
case "header-three":
|
||||||
pushBlock([`### ${text}`], "heading");
|
pushBlock([`### ${text}`], "heading");
|
||||||
|
pushTrailingMedia(collectMediaLines(block));
|
||||||
break;
|
break;
|
||||||
case "header-four":
|
case "header-four":
|
||||||
pushBlock([`#### ${text}`], "heading");
|
pushBlock([`#### ${text}`], "heading");
|
||||||
|
pushTrailingMedia(collectMediaLines(block));
|
||||||
break;
|
break;
|
||||||
case "header-five":
|
case "header-five":
|
||||||
pushBlock([`##### ${text}`], "heading");
|
pushBlock([`##### ${text}`], "heading");
|
||||||
|
pushTrailingMedia(collectMediaLines(block));
|
||||||
break;
|
break;
|
||||||
case "header-six":
|
case "header-six":
|
||||||
pushBlock([`###### ${text}`], "heading");
|
pushBlock([`###### ${text}`], "heading");
|
||||||
|
pushTrailingMedia(collectMediaLines(block));
|
||||||
break;
|
break;
|
||||||
case "blockquote": {
|
case "blockquote": {
|
||||||
const quoteLines = text.length > 0 ? text.split("\n") : [""];
|
const quoteLines = text.length > 0 ? text.split("\n") : [""];
|
||||||
pushBlock(quoteLines.map((line) => `> ${line}`), "quote");
|
pushBlock(quoteLines.map((line) => `> ${line}`), "quote");
|
||||||
|
pushTrailingMedia(collectMediaLines(block));
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
|
if (/^XIMGPH_\d+$/.test(text.trim())) {
|
||||||
|
pushTrailingMedia(collectMediaLines(block));
|
||||||
|
break;
|
||||||
|
}
|
||||||
pushBlock([text], "text");
|
pushBlock([text], "text");
|
||||||
|
pushTrailingMedia(collectMediaLines(block));
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -371,7 +536,28 @@ export type FormatArticleResult = {
|
|||||||
coverUrl: string | null;
|
coverUrl: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function formatArticleMarkdown(article: unknown): FormatArticleResult {
|
export function extractReferencedTweetIds(article: unknown): string[] {
|
||||||
|
const candidate = coerceArticleEntity(article);
|
||||||
|
const entityMap = candidate?.content_state?.entityMap;
|
||||||
|
if (!entityMap) return [];
|
||||||
|
|
||||||
|
const ids: string[] = [];
|
||||||
|
const seen = new Set<string>();
|
||||||
|
for (const entry of Object.values(entityMap)) {
|
||||||
|
const value = entry?.value;
|
||||||
|
if (value?.type !== "TWEET") continue;
|
||||||
|
const tweetId = typeof value.data?.tweetId === "string" ? value.data.tweetId : "";
|
||||||
|
if (!tweetId || seen.has(tweetId)) continue;
|
||||||
|
seen.add(tweetId);
|
||||||
|
ids.push(tweetId);
|
||||||
|
}
|
||||||
|
return ids;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatArticleMarkdown(
|
||||||
|
article: unknown,
|
||||||
|
options: FormatArticleOptions = {}
|
||||||
|
): FormatArticleResult {
|
||||||
const candidate = coerceArticleEntity(article);
|
const candidate = coerceArticleEntity(article);
|
||||||
if (!candidate) {
|
if (!candidate) {
|
||||||
return { markdown: `\`\`\`json\n${JSON.stringify(article, null, 2)}\n\`\`\``, coverUrl: null };
|
return { markdown: `\`\`\`json\n${JSON.stringify(article, null, 2)}\n\`\`\``, coverUrl: null };
|
||||||
@@ -392,10 +578,18 @@ 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;
|
||||||
|
const entityLookup = buildEntityLookup(entityMap);
|
||||||
if (Array.isArray(blocks) && blocks.length > 0) {
|
if (Array.isArray(blocks) && blocks.length > 0) {
|
||||||
const atomicMediaQueue = buildAtomicMediaQueue(candidate, usedUrls);
|
|
||||||
const mediaLinkMap = buildMediaLinkMap(entityMap);
|
const mediaLinkMap = buildMediaLinkMap(entityMap);
|
||||||
const rendered = renderContentBlocks(blocks, entityMap, mediaById, usedUrls, atomicMediaQueue, mediaLinkMap);
|
const rendered = renderContentBlocks(
|
||||||
|
blocks,
|
||||||
|
entityMap,
|
||||||
|
entityLookup,
|
||||||
|
mediaById,
|
||||||
|
usedUrls,
|
||||||
|
mediaLinkMap,
|
||||||
|
options.referencedTweets
|
||||||
|
);
|
||||||
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);
|
||||||
|
|||||||
@@ -187,11 +187,35 @@ function buildFileName(kind: MediaKind, index: number, sourceUrl: string, extens
|
|||||||
|
|
||||||
const FRONTMATTER_COVER_RE = /^(coverImage:\s*")(https?:\/\/[^"]+)(")/m;
|
const FRONTMATTER_COVER_RE = /^(coverImage:\s*")(https?:\/\/[^"]+)(")/m;
|
||||||
|
|
||||||
|
function toHighResUrl(rawUrl: string): string {
|
||||||
|
try {
|
||||||
|
const parsed = new URL(rawUrl);
|
||||||
|
if (parsed.hostname !== "pbs.twimg.com") return rawUrl;
|
||||||
|
const ext = path.posix.extname(parsed.pathname).replace(/^\./, "").toLowerCase();
|
||||||
|
if (!ext || !IMAGE_EXTENSIONS.has(ext)) return rawUrl;
|
||||||
|
parsed.pathname = parsed.pathname.replace(new RegExp(`\\.${ext}$`), "");
|
||||||
|
parsed.searchParams.set("format", ext === "jpeg" ? "jpg" : ext);
|
||||||
|
parsed.searchParams.set("name", "4096x4096");
|
||||||
|
return parsed.toString();
|
||||||
|
} catch {
|
||||||
|
return rawUrl;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function collectMarkdownLinkCandidates(markdown: string): MarkdownLinkCandidate[] {
|
function collectMarkdownLinkCandidates(markdown: string): MarkdownLinkCandidate[] {
|
||||||
MARKDOWN_LINK_RE.lastIndex = 0;
|
|
||||||
const candidates: MarkdownLinkCandidate[] = [];
|
const candidates: MarkdownLinkCandidate[] = [];
|
||||||
const seen = new Set<string>();
|
const seen = new Set<string>();
|
||||||
|
|
||||||
|
const fmMatch = markdown.match(/^---\n([\s\S]*?)\n---/);
|
||||||
|
if (fmMatch) {
|
||||||
|
const coverMatch = fmMatch[1]?.match(FRONTMATTER_COVER_RE);
|
||||||
|
if (coverMatch?.[2] && !seen.has(coverMatch[2])) {
|
||||||
|
seen.add(coverMatch[2]);
|
||||||
|
candidates.push({ url: coverMatch[2], hint: "image" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
MARKDOWN_LINK_RE.lastIndex = 0;
|
||||||
let match: RegExpExecArray | null;
|
let match: RegExpExecArray | null;
|
||||||
while ((match = MARKDOWN_LINK_RE.exec(markdown))) {
|
while ((match = MARKDOWN_LINK_RE.exec(markdown))) {
|
||||||
const label = match[1] ?? "";
|
const label = match[1] ?? "";
|
||||||
@@ -204,15 +228,6 @@ function collectMarkdownLinkCandidates(markdown: string): MarkdownLinkCandidate[
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const fmMatch = markdown.match(/^---\n([\s\S]*?)\n---/);
|
|
||||||
if (fmMatch) {
|
|
||||||
const coverMatch = fmMatch[1]?.match(FRONTMATTER_COVER_RE);
|
|
||||||
if (coverMatch?.[2] && !seen.has(coverMatch[2])) {
|
|
||||||
seen.add(coverMatch[2]);
|
|
||||||
candidates.push({ url: coverMatch[2], hint: "image" });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return candidates;
|
return candidates;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -259,7 +274,8 @@ export async function localizeMarkdownMedia(
|
|||||||
|
|
||||||
for (const candidate of candidates) {
|
for (const candidate of candidates) {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(candidate.url, {
|
const downloadUrl = toHighResUrl(candidate.url);
|
||||||
|
const response = await fetch(downloadUrl, {
|
||||||
method: "GET",
|
method: "GET",
|
||||||
redirect: "follow",
|
redirect: "follow",
|
||||||
headers: {
|
headers: {
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import { fetchXTweet } from "./graphql.js";
|
||||||
|
import {
|
||||||
|
extractReferencedTweetIds,
|
||||||
|
type ReferencedTweetInfo,
|
||||||
|
} from "./markdown.js";
|
||||||
|
|
||||||
|
type ResolveReferencedTweetsOptions = {
|
||||||
|
log?: (message: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
function extractReferencedTweetInfo(tweet: any, fallbackTweetId: string): ReferencedTweetInfo {
|
||||||
|
const userCore = tweet?.core?.user_results?.result?.core;
|
||||||
|
const userLegacy = tweet?.core?.user_results?.result?.legacy;
|
||||||
|
|
||||||
|
const authorName =
|
||||||
|
typeof userCore?.name === "string"
|
||||||
|
? userCore.name
|
||||||
|
: typeof userLegacy?.name === "string"
|
||||||
|
? userLegacy.name
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const authorUsername =
|
||||||
|
typeof userCore?.screen_name === "string"
|
||||||
|
? userCore.screen_name
|
||||||
|
: typeof userLegacy?.screen_name === "string"
|
||||||
|
? userLegacy.screen_name
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const text =
|
||||||
|
tweet?.note_tweet?.note_tweet_results?.result?.text ??
|
||||||
|
tweet?.legacy?.full_text ??
|
||||||
|
tweet?.legacy?.text ??
|
||||||
|
undefined;
|
||||||
|
|
||||||
|
const tweetId =
|
||||||
|
typeof tweet?.rest_id === "string" && tweet.rest_id.length > 0
|
||||||
|
? tweet.rest_id
|
||||||
|
: fallbackTweetId;
|
||||||
|
|
||||||
|
const url = authorUsername
|
||||||
|
? `https://x.com/${authorUsername}/status/${tweetId}`
|
||||||
|
: `https://x.com/i/web/status/${tweetId}`;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: tweetId,
|
||||||
|
url,
|
||||||
|
authorName,
|
||||||
|
authorUsername,
|
||||||
|
text: typeof text === "string" ? text : undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function resolveReferencedTweetsFromArticle(
|
||||||
|
article: unknown,
|
||||||
|
cookieMap: Record<string, string>,
|
||||||
|
options: ResolveReferencedTweetsOptions = {}
|
||||||
|
): Promise<Map<string, ReferencedTweetInfo>> {
|
||||||
|
const log = options.log ?? (() => {});
|
||||||
|
const ids = extractReferencedTweetIds(article);
|
||||||
|
const referencedTweets = new Map<string, ReferencedTweetInfo>();
|
||||||
|
|
||||||
|
for (const id of ids) {
|
||||||
|
try {
|
||||||
|
const tweet = await fetchXTweet(id, cookieMap, false);
|
||||||
|
const info = extractReferencedTweetInfo(tweet, id);
|
||||||
|
referencedTweets.set(id, info);
|
||||||
|
} catch (error) {
|
||||||
|
log(
|
||||||
|
`[x-to-markdown] Failed to fetch referenced tweet ${id}: ${
|
||||||
|
error instanceof Error ? error.message : String(error)
|
||||||
|
}`
|
||||||
|
);
|
||||||
|
referencedTweets.set(id, {
|
||||||
|
id,
|
||||||
|
url: `https://x.com/i/web/status/${id}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return referencedTweets;
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import { fileURLToPath } from "node:url";
|
|||||||
import { hasRequiredXCookies, loadXCookies } from "./cookies.js";
|
import { hasRequiredXCookies, loadXCookies } from "./cookies.js";
|
||||||
import { fetchTweetThread } from "./thread.js";
|
import { fetchTweetThread } from "./thread.js";
|
||||||
import { formatArticleMarkdown } from "./markdown.js";
|
import { formatArticleMarkdown } from "./markdown.js";
|
||||||
|
import { resolveReferencedTweetsFromArticle } from "./referenced-tweets.js";
|
||||||
import { formatThreadTweetsMarkdown } from "./thread-markdown.js";
|
import { formatThreadTweetsMarkdown } from "./thread-markdown.js";
|
||||||
import { resolveArticleEntityFromTweet } from "./tweet-article.js";
|
import { resolveArticleEntityFromTweet } from "./tweet-article.js";
|
||||||
|
|
||||||
@@ -129,7 +130,8 @@ export async function tweetToMarkdown(
|
|||||||
const parts: string[] = [];
|
const parts: string[] = [];
|
||||||
|
|
||||||
if (articleEntity) {
|
if (articleEntity) {
|
||||||
const articleResult = formatArticleMarkdown(articleEntity);
|
const referencedTweets = await resolveReferencedTweetsFromArticle(articleEntity, cookieMap, { log });
|
||||||
|
const articleResult = formatArticleMarkdown(articleEntity, { referencedTweets });
|
||||||
coverImage = articleResult.coverUrl;
|
coverImage = articleResult.coverUrl;
|
||||||
const articleMarkdown = articleResult.markdown.trimEnd();
|
const articleMarkdown = articleResult.markdown.trimEnd();
|
||||||
if (articleMarkdown) {
|
if (articleMarkdown) {
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ export type ArticleEntityMapEntry = {
|
|||||||
caption?: string;
|
caption?: string;
|
||||||
mediaItems?: ArticleEntityMapMediaItem[];
|
mediaItems?: ArticleEntityMapMediaItem[];
|
||||||
url?: string;
|
url?: string;
|
||||||
|
tweetId?: string;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -92,6 +92,8 @@ test -f "$HOME/.baoyu-skills/baoyu-post-to-wechat/EXTEND.md" && grep -o 'default
|
|||||||
| `default` (Recommended) | 经典主题 - 传统排版,标题居中带底边,二级标题白字彩底 |
|
| `default` (Recommended) | 经典主题 - 传统排版,标题居中带底边,二级标题白字彩底 |
|
||||||
| `grace` | 优雅主题 - 文字阴影,圆角卡片,精致引用块 |
|
| `grace` | 优雅主题 - 文字阴影,圆角卡片,精致引用块 |
|
||||||
| `simple` | 简洁主题 - 现代极简风,不对称圆角,清爽留白 |
|
| `simple` | 简洁主题 - 现代极简风,不对称圆角,清爽留白 |
|
||||||
|
| `red` | 红色主题 - 红金配色,宋体排版,传统书法风格 |
|
||||||
|
| `orange` | 橙色主题 - 暖色调现代风,宽松行距,圆角装饰 |
|
||||||
|
|
||||||
### Step 2: Convert
|
### Step 2: Convert
|
||||||
|
|
||||||
@@ -113,7 +115,7 @@ npx -y bun ${SKILL_DIR}/scripts/main.ts <markdown_file> [options]
|
|||||||
|
|
||||||
| Option | Description | Default |
|
| Option | Description | Default |
|
||||||
|--------|-------------|---------|
|
|--------|-------------|---------|
|
||||||
| `--theme <name>` | Theme name (default, grace, simple) | default |
|
| `--theme <name>` | Theme name (default, grace, simple, red, orange) | default |
|
||||||
| `--title <title>` | Override title from frontmatter | |
|
| `--title <title>` | Override title from frontmatter | |
|
||||||
| `--keep-title` | Keep the first heading in content | false (removed) |
|
| `--keep-title` | Keep the first heading in content | false (removed) |
|
||||||
| `--help` | Show help | |
|
| `--help` | Show help | |
|
||||||
@@ -169,6 +171,8 @@ npx -y bun ${SKILL_DIR}/scripts/main.ts article.md --title "My Article"
|
|||||||
| `default` | 经典主题 - 传统排版,标题居中带底边,二级标题白字彩底 |
|
| `default` | 经典主题 - 传统排版,标题居中带底边,二级标题白字彩底 |
|
||||||
| `grace` | 优雅主题 - 文字阴影,圆角卡片,精致引用块 (by @brzhang) |
|
| `grace` | 优雅主题 - 文字阴影,圆角卡片,精致引用块 (by @brzhang) |
|
||||||
| `simple` | 简洁主题 - 现代极简风,不对称圆角,清爽留白 (by @okooo5km) |
|
| `simple` | 简洁主题 - 现代极简风,不对称圆角,清爽留白 (by @okooo5km) |
|
||||||
|
| `red` | 红色主题 - 红金配色,宋体排版,传统书法风格 |
|
||||||
|
| `orange` | 橙色主题 - 暖色调现代风,宽松行距,圆角装饰 |
|
||||||
|
|
||||||
## Supported Markdown Features
|
## Supported Markdown Features
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,180 @@
|
|||||||
|
/**
|
||||||
|
* MD 橙色主题
|
||||||
|
* 暖色调现代风,橙色主色,宽松行距
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* ==================== 容器样式覆盖 ==================== */
|
||||||
|
section,
|
||||||
|
container {
|
||||||
|
font-family: PingFang SC, system-ui, -apple-system, BlinkMacSystemFont, Helvetica Neue, Hiragino Sans GB, Microsoft YaHei UI, Microsoft YaHei, Arial, sans-serif;
|
||||||
|
font-size: 15px;
|
||||||
|
line-height: 2;
|
||||||
|
background-color: rgba(250, 249, 245, 1);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.01);
|
||||||
|
border-radius: 25px;
|
||||||
|
padding: 12px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#output {
|
||||||
|
font-family: PingFang SC, system-ui, -apple-system, BlinkMacSystemFont, Helvetica Neue, Hiragino Sans GB, Microsoft YaHei UI, Microsoft YaHei, Arial, sans-serif;
|
||||||
|
font-size: 15px;
|
||||||
|
line-height: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==================== 一级标题 ==================== */
|
||||||
|
h1 {
|
||||||
|
display: table;
|
||||||
|
padding: 0.3em 1em;
|
||||||
|
margin: 20px auto;
|
||||||
|
color: #3B3B38;
|
||||||
|
background: #D97757;
|
||||||
|
border-radius: 15px;
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: bold;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==================== 二级标题 ==================== */
|
||||||
|
h2 {
|
||||||
|
display: block;
|
||||||
|
padding: 0.2em 0;
|
||||||
|
padding-bottom: 0;
|
||||||
|
margin: 0 auto 20px;
|
||||||
|
width: 100%;
|
||||||
|
color: #D97757;
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: bold;
|
||||||
|
border-bottom: 2px solid #E4B1A0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==================== 三级标题 ==================== */
|
||||||
|
h3 {
|
||||||
|
margin: 0 8px 10px;
|
||||||
|
color: #3B3B38;
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==================== 四级标题 ==================== */
|
||||||
|
h4 {
|
||||||
|
margin: 0 8px 10px;
|
||||||
|
color: #D97757;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==================== 五级标题 ==================== */
|
||||||
|
h5 {
|
||||||
|
display: inline-block;
|
||||||
|
margin: 0 8px 10px;
|
||||||
|
padding: 4px 10px;
|
||||||
|
color: #3B3B38;
|
||||||
|
background: rgba(255, 255, 255, 0.7);
|
||||||
|
border: 1px solid rgb(189, 224, 254);
|
||||||
|
border-radius: 20px;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==================== 六级标题 ==================== */
|
||||||
|
h6 {
|
||||||
|
margin: 0 8px 10px;
|
||||||
|
color: #D97757;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==================== 段落 ==================== */
|
||||||
|
p {
|
||||||
|
margin: 20px 0;
|
||||||
|
color: #3B3B38;
|
||||||
|
line-height: 2;
|
||||||
|
letter-spacing: 0px;
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==================== 引用块 ==================== */
|
||||||
|
blockquote {
|
||||||
|
font-style: normal;
|
||||||
|
padding: 15px 12px;
|
||||||
|
border-left: 7px solid rgba(228, 177, 160, 1);
|
||||||
|
border-radius: 10px;
|
||||||
|
color: #3B3B38;
|
||||||
|
background-color: rgba(255, 255, 255, 0.6);
|
||||||
|
margin: 12px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
blockquote > p {
|
||||||
|
color: #3B3B38;
|
||||||
|
font-size: 14px;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==================== 代码块 ==================== */
|
||||||
|
pre.code__pre,
|
||||||
|
.hljs.code__pre {
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==================== 图片 ==================== */
|
||||||
|
img {
|
||||||
|
border-radius: 10px;
|
||||||
|
margin: 5px auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==================== 列表 ==================== */
|
||||||
|
ol {
|
||||||
|
padding-left: 1em;
|
||||||
|
margin: 15px 0;
|
||||||
|
line-height: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
ul {
|
||||||
|
list-style: none;
|
||||||
|
padding-left: 0;
|
||||||
|
margin: 15px 0;
|
||||||
|
line-height: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
li {
|
||||||
|
margin: 0.2em 0;
|
||||||
|
color: #3B3B38;
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==================== 分隔线 ==================== */
|
||||||
|
hr {
|
||||||
|
border-style: solid;
|
||||||
|
border-width: 1px 0 0;
|
||||||
|
border-color: #D97757;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==================== 强调 ==================== */
|
||||||
|
strong {
|
||||||
|
color: #3B3B38;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==================== 标记高亮 ==================== */
|
||||||
|
.markup-highlight {
|
||||||
|
background-color: #3B3B3B;
|
||||||
|
padding: 10px;
|
||||||
|
color: #FAF9F5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markup-underline {
|
||||||
|
text-decoration: underline;
|
||||||
|
text-decoration-color: #E4B1A0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==================== 链接 ==================== */
|
||||||
|
a {
|
||||||
|
color: #D97757;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==================== 表格 ==================== */
|
||||||
|
th {
|
||||||
|
background: rgba(217, 119, 87, 0.1);
|
||||||
|
}
|
||||||
@@ -0,0 +1,182 @@
|
|||||||
|
/**
|
||||||
|
* MD 红色主题
|
||||||
|
* 传统书法风格,红金配色,宋体排版
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* ==================== 容器字体覆盖 ==================== */
|
||||||
|
section,
|
||||||
|
container {
|
||||||
|
font-family: "Source Han Serif SC", "Noto Serif CJK SC", "Source Han Serif CN", STSong, SimSun, serif;
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 1.8;
|
||||||
|
background-color: rgba(255, 251, 240, 1);
|
||||||
|
border: 4px solid rgba(169, 50, 38, 1);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 6px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#output {
|
||||||
|
font-family: "Source Han Serif SC", "Noto Serif CJK SC", "Source Han Serif CN", STSong, SimSun, serif;
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 1.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==================== 一级标题 ==================== */
|
||||||
|
h1 {
|
||||||
|
display: table;
|
||||||
|
padding: 0 1em;
|
||||||
|
border-bottom: 2px solid #A93226;
|
||||||
|
margin: 30px auto 20px;
|
||||||
|
color: #A93226;
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: bold;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==================== 二级标题 ==================== */
|
||||||
|
h2 {
|
||||||
|
display: block;
|
||||||
|
width: fit-content;
|
||||||
|
padding: 6px 20px;
|
||||||
|
margin: 30px auto 20px;
|
||||||
|
color: #FFFFFF;
|
||||||
|
background-color: #A93226;
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: bold;
|
||||||
|
text-align: center;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: rgba(0, 0, 0, 0.1) 0px 4px 6px;
|
||||||
|
line-height: 1.6em;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==================== 三级标题 ==================== */
|
||||||
|
h3 {
|
||||||
|
padding-left: 8px;
|
||||||
|
border-left: 3px solid #A93226;
|
||||||
|
margin: 20px 8px 10px 0;
|
||||||
|
color: #A93226;
|
||||||
|
font-size: 17px;
|
||||||
|
font-weight: normal;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==================== 四级标题 ==================== */
|
||||||
|
h4 {
|
||||||
|
margin: 10px 8px;
|
||||||
|
color: #D4AC0D;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==================== 五级标题 ==================== */
|
||||||
|
h5 {
|
||||||
|
margin: 10px 8px;
|
||||||
|
color: #797D7F;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==================== 六级标题 ==================== */
|
||||||
|
h6 {
|
||||||
|
margin: 10px 8px;
|
||||||
|
color: #797D7F;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==================== 段落 ==================== */
|
||||||
|
p {
|
||||||
|
margin: 16px 0;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
color: #2C2C2C;
|
||||||
|
line-height: 1.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==================== 引用块 ==================== */
|
||||||
|
blockquote {
|
||||||
|
font-style: normal;
|
||||||
|
padding: 15px 12px;
|
||||||
|
border-left: 2px solid rgba(169, 50, 38, 0.8);
|
||||||
|
border-radius: 4px;
|
||||||
|
color: #5D4037;
|
||||||
|
background-color: rgba(253, 237, 236, 1);
|
||||||
|
margin: 12px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
blockquote > p {
|
||||||
|
color: #5D4037;
|
||||||
|
font-size: 14px;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==================== 代码块 ==================== */
|
||||||
|
pre.code__pre,
|
||||||
|
.hljs.code__pre {
|
||||||
|
font-size: 13px;
|
||||||
|
border: 1px solid #D4AC0D;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==================== 图片 ==================== */
|
||||||
|
img {
|
||||||
|
border-radius: 4px;
|
||||||
|
border: 1px solid #D4AC0D;
|
||||||
|
margin: 20px auto;
|
||||||
|
padding: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==================== 列表 ==================== */
|
||||||
|
ol {
|
||||||
|
padding-left: 1em;
|
||||||
|
margin: 15px 0;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
ul {
|
||||||
|
list-style: none;
|
||||||
|
padding-left: 0;
|
||||||
|
margin: 15px 0;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
li {
|
||||||
|
margin: 0.2em 0;
|
||||||
|
color: #2C2C2C;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==================== 分隔线 ==================== */
|
||||||
|
hr {
|
||||||
|
border-style: solid;
|
||||||
|
border-width: 2px 0 0;
|
||||||
|
border-color: #D4AC0D;
|
||||||
|
margin: 40px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==================== 强调 ==================== */
|
||||||
|
strong {
|
||||||
|
color: #A93226;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==================== 标记高亮 ==================== */
|
||||||
|
.markup-highlight {
|
||||||
|
background-color: #FADBD8;
|
||||||
|
padding: 5px;
|
||||||
|
color: #A93226;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markup-underline {
|
||||||
|
text-decoration: underline;
|
||||||
|
text-decoration-color: #D4AC0D;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==================== 链接 ==================== */
|
||||||
|
a {
|
||||||
|
color: #A93226;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==================== 表格 ==================== */
|
||||||
|
th {
|
||||||
|
background: rgba(169, 50, 38, 0.1);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user