@astro-minimax/cli 0.5.0 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +69 -0
- package/dist/commands/ai.d.ts +2 -0
- package/dist/commands/ai.d.ts.map +1 -0
- package/dist/commands/ai.js +99 -0
- package/dist/commands/ai.js.map +1 -0
- package/dist/commands/data.d.ts +2 -0
- package/dist/commands/data.d.ts.map +1 -0
- package/dist/commands/data.js +111 -0
- package/dist/commands/data.js.map +1 -0
- package/dist/commands/hooks.d.ts +2 -0
- package/dist/commands/hooks.d.ts.map +1 -0
- package/dist/commands/hooks.js +378 -0
- package/dist/commands/hooks.js.map +1 -0
- package/dist/commands/init.d.ts +2 -0
- package/dist/commands/init.d.ts.map +1 -0
- package/dist/commands/init.js +50 -0
- package/dist/commands/init.js.map +1 -0
- package/dist/commands/post.d.ts +2 -0
- package/dist/commands/post.d.ts.map +1 -0
- package/dist/commands/post.js +190 -0
- package/dist/commands/post.js.map +1 -0
- package/dist/commands/profile.d.ts +2 -0
- package/dist/commands/profile.d.ts.map +1 -0
- package/dist/commands/profile.js +88 -0
- package/dist/commands/profile.js.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +81 -0
- package/dist/index.js.map +1 -0
- package/dist/tools/ai-process.d.ts +20 -0
- package/dist/tools/ai-process.d.ts.map +1 -0
- package/dist/tools/ai-process.js +607 -0
- package/dist/tools/ai-process.js.map +1 -0
- package/dist/tools/build-author-context.d.ts +13 -0
- package/dist/tools/build-author-context.d.ts.map +1 -0
- package/dist/tools/build-author-context.js +313 -0
- package/dist/tools/build-author-context.js.map +1 -0
- package/dist/tools/build-voice-profile.d.ts +12 -0
- package/dist/tools/build-voice-profile.d.ts.map +1 -0
- package/dist/tools/build-voice-profile.js +270 -0
- package/dist/tools/build-voice-profile.js.map +1 -0
- package/dist/tools/eval-ai-chat.d.ts +17 -0
- package/dist/tools/eval-ai-chat.d.ts.map +1 -0
- package/dist/tools/eval-ai-chat.js +332 -0
- package/dist/tools/eval-ai-chat.js.map +1 -0
- package/dist/tools/generate-author-profile.d.ts +14 -0
- package/dist/tools/generate-author-profile.d.ts.map +1 -0
- package/dist/tools/generate-author-profile.js +289 -0
- package/dist/tools/generate-author-profile.js.map +1 -0
- package/dist/tools/generate-cover.d.ts +14 -0
- package/dist/tools/generate-cover.d.ts.map +1 -0
- package/dist/tools/generate-cover.js +95 -0
- package/dist/tools/generate-cover.js.map +1 -0
- package/dist/tools/generate-og.d.ts +3 -0
- package/dist/tools/generate-og.d.ts.map +1 -0
- package/dist/tools/generate-og.js +254 -0
- package/dist/tools/generate-og.js.map +1 -0
- package/dist/tools/generate-related.d.ts +11 -0
- package/dist/tools/generate-related.d.ts.map +1 -0
- package/dist/tools/generate-related.js +124 -0
- package/dist/tools/generate-related.js.map +1 -0
- package/dist/tools/generate-tags.d.ts +14 -0
- package/dist/tools/generate-tags.d.ts.map +1 -0
- package/dist/tools/generate-tags.js +182 -0
- package/dist/tools/generate-tags.js.map +1 -0
- package/dist/tools/lib/ai-provider.d.ts +43 -0
- package/dist/tools/lib/ai-provider.d.ts.map +1 -0
- package/dist/tools/lib/ai-provider.js +146 -0
- package/dist/tools/lib/ai-provider.js.map +1 -0
- package/dist/tools/lib/frontmatter.d.ts +11 -0
- package/dist/tools/lib/frontmatter.d.ts.map +1 -0
- package/dist/tools/lib/frontmatter.js +80 -0
- package/dist/tools/lib/frontmatter.js.map +1 -0
- package/dist/tools/lib/index.d.ts +7 -0
- package/dist/tools/lib/index.d.ts.map +1 -0
- package/{template/tools/lib/index.ts → dist/tools/lib/index.js} +1 -0
- package/dist/tools/lib/index.js.map +1 -0
- package/dist/tools/lib/markdown.d.ts +6 -0
- package/dist/tools/lib/markdown.d.ts.map +1 -0
- package/dist/tools/lib/markdown.js +34 -0
- package/dist/tools/lib/markdown.js.map +1 -0
- package/dist/tools/lib/posts.d.ts +25 -0
- package/dist/tools/lib/posts.d.ts.map +1 -0
- package/dist/tools/lib/posts.js +63 -0
- package/dist/tools/lib/posts.js.map +1 -0
- package/dist/tools/lib/utils.d.ts +18 -0
- package/dist/tools/lib/utils.d.ts.map +1 -0
- package/dist/tools/lib/utils.js +121 -0
- package/dist/tools/lib/utils.js.map +1 -0
- package/dist/tools/lib/vectors.d.ts +27 -0
- package/dist/tools/lib/vectors.d.ts.map +1 -0
- package/dist/tools/lib/vectors.js +64 -0
- package/dist/tools/lib/vectors.js.map +1 -0
- package/dist/tools/summarize.d.ts +16 -0
- package/dist/tools/summarize.d.ts.map +1 -0
- package/dist/tools/summarize.js +108 -0
- package/dist/tools/summarize.js.map +1 -0
- package/dist/tools/translate.d.ts +13 -0
- package/dist/tools/translate.d.ts.map +1 -0
- package/dist/tools/translate.js +46 -0
- package/dist/tools/translate.js.map +1 -0
- package/dist/tools/vectorize.d.ts +13 -0
- package/dist/tools/vectorize.d.ts.map +1 -0
- package/dist/tools/vectorize.js +87 -0
- package/dist/tools/vectorize.js.map +1 -0
- package/package.json +14 -9
- package/template/astro.config.ts +8 -28
- package/template/datas/ai-seo.json +8 -0
- package/template/datas/ai-skip-list.json +1 -0
- package/template/datas/author-profile-context.json +21 -0
- package/template/datas/author-profile-report.json +21 -0
- package/template/datas/eval/gold-set.json +72 -0
- package/template/functions/README.md +82 -0
- package/template/functions/api/ai-info.ts +2 -2
- package/template/functions/api/chat.ts +4 -1
- package/template/functions/api/notify/comment.ts +140 -68
- package/template/functions/api/notify/debug.ts +41 -0
- package/template/functions/api/notify/status.ts +97 -0
- package/template/functions/api/notify/test-ai-chat.ts +67 -0
- package/template/package.json +22 -25
- package/template/src/config.ts +11 -0
- package/template/src/content.config.ts +29 -16
- package/template/src/env.d.ts +0 -5
- package/index.js +0 -36
- package/template/tools/README.md +0 -169
- package/template/tools/ai-process.ts +0 -816
- package/template/tools/build-author-context.ts +0 -405
- package/template/tools/build-voice-profile.ts +0 -322
- package/template/tools/generate-author-profile.ts +0 -369
- package/template/tools/generate-cover.ts +0 -123
- package/template/tools/generate-og.ts +0 -280
- package/template/tools/generate-related.ts +0 -146
- package/template/tools/generate-tags.ts +0 -251
- package/template/tools/lib/ai-provider.ts +0 -240
- package/template/tools/lib/frontmatter.ts +0 -94
- package/template/tools/lib/markdown.ts +0 -40
- package/template/tools/lib/posts.ts +0 -89
- package/template/tools/lib/utils.ts +0 -138
- package/template/tools/lib/vectors.ts +0 -96
- package/template/tools/summarize.ts +0 -142
- package/template/tools/translate.ts +0 -60
- package/template/tools/vectorize.ts +0 -105
|
@@ -1,123 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env npx tsx
|
|
2
|
-
/**
|
|
3
|
-
* AI 封面图生成工具
|
|
4
|
-
*
|
|
5
|
-
* 用法:
|
|
6
|
-
* pnpm run tools:cover <文章路径> # 生成封面图
|
|
7
|
-
* pnpm run tools:cover <文章路径> --write # 生成并写入 frontmatter
|
|
8
|
-
* pnpm run tools:cover <文章路径> --style <风格> # 指定风格 (abstract|minimal|tech|illustration)
|
|
9
|
-
*
|
|
10
|
-
* 环境变量:
|
|
11
|
-
* AI_API_KEY / OPENAI_API_KEY — API key
|
|
12
|
-
*/
|
|
13
|
-
|
|
14
|
-
import { readFile, writeFile, mkdir } from "node:fs/promises";
|
|
15
|
-
import { join, basename } from "node:path";
|
|
16
|
-
import { extractFrontmatter } from "./lib/frontmatter.js";
|
|
17
|
-
import { generateImage } from "./lib/ai-provider.js";
|
|
18
|
-
|
|
19
|
-
const COVERS_DIR = join(process.cwd(), "src/assets/covers");
|
|
20
|
-
|
|
21
|
-
interface GenerateOptions {
|
|
22
|
-
filePath: string;
|
|
23
|
-
style: string;
|
|
24
|
-
write: boolean;
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
function parseArgs(): GenerateOptions {
|
|
28
|
-
const args = process.argv.slice(2);
|
|
29
|
-
const filePath = args.find(a => !a.startsWith("--"));
|
|
30
|
-
const style = args.includes("--style")
|
|
31
|
-
? args[args.indexOf("--style") + 1] || "abstract"
|
|
32
|
-
: "abstract";
|
|
33
|
-
const write = args.includes("--write");
|
|
34
|
-
|
|
35
|
-
if (!filePath) {
|
|
36
|
-
console.error(
|
|
37
|
-
"用法: pnpm run tools:cover <文章路径> [--write] [--style abstract|minimal|tech|illustration]"
|
|
38
|
-
);
|
|
39
|
-
process.exit(1);
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
return { filePath, style, write };
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
const STYLE_PROMPTS: Record<string, string> = {
|
|
46
|
-
abstract:
|
|
47
|
-
"Create an abstract, modern cover image with geometric shapes, gradients and soft lighting. The composition should feel clean, professional and tech-oriented.",
|
|
48
|
-
minimal:
|
|
49
|
-
"Create a minimalist cover image with plenty of negative space, subtle textures and a refined color palette. Simple, elegant composition.",
|
|
50
|
-
tech: "Create a technology-themed cover image with digital elements like circuit patterns, code fragments, data visualizations. Modern and futuristic.",
|
|
51
|
-
illustration:
|
|
52
|
-
"Create a flat illustration style cover image with bold colors and simplified shapes. Friendly and approachable design.",
|
|
53
|
-
};
|
|
54
|
-
|
|
55
|
-
function generateFileName(title: string): string {
|
|
56
|
-
const slug = title
|
|
57
|
-
.toLowerCase()
|
|
58
|
-
.replace(/[\u4e00-\u9fff]/g, m => m)
|
|
59
|
-
.replace(/[^a-z0-9\u4e00-\u9fff]+/g, "-")
|
|
60
|
-
.replace(/^-|-$/g, "")
|
|
61
|
-
.slice(0, 50);
|
|
62
|
-
return `cover-${slug}-${Date.now().toString(36)}.png`;
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
async function main() {
|
|
66
|
-
const { filePath, style, write } = parseArgs();
|
|
67
|
-
|
|
68
|
-
const fullPath = join(process.cwd(), filePath);
|
|
69
|
-
const content = await readFile(fullPath, "utf-8");
|
|
70
|
-
const { data } = extractFrontmatter(content);
|
|
71
|
-
|
|
72
|
-
const title = (data.title as string) || basename(filePath, ".md");
|
|
73
|
-
const description = (data.description as string) || "";
|
|
74
|
-
|
|
75
|
-
if (data.ogImage && !write) {
|
|
76
|
-
console.log(`⚠️ 文章已有 ogImage: ${data.ogImage}`);
|
|
77
|
-
console.log(` 使用 --write 强制覆盖\n`);
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
const stylePrompt = STYLE_PROMPTS[style] || STYLE_PROMPTS.abstract;
|
|
81
|
-
const prompt = `${stylePrompt}\n\nThis is for a blog post titled: "${title}"\nDescription: "${description}"\n\nDo NOT include any text, letters, numbers or words in the image. Pure visual design only. Aspect ratio: 16:9, high quality.`;
|
|
82
|
-
|
|
83
|
-
console.log(`🎨 生成封面图...`);
|
|
84
|
-
console.log(` 风格: ${style}`);
|
|
85
|
-
console.log(` 标题: ${title}\n`);
|
|
86
|
-
|
|
87
|
-
const imageBuffer = await generateImage(prompt);
|
|
88
|
-
|
|
89
|
-
await mkdir(COVERS_DIR, { recursive: true });
|
|
90
|
-
const fileName = generateFileName(title);
|
|
91
|
-
const outputPath = join(COVERS_DIR, fileName);
|
|
92
|
-
await writeFile(outputPath, imageBuffer);
|
|
93
|
-
|
|
94
|
-
const relativePath = `../../../assets/covers/${fileName}`;
|
|
95
|
-
console.log(`\n✅ 封面图已生成: ${outputPath}`);
|
|
96
|
-
console.log(` 相对路径 (用于 frontmatter): ${relativePath}`);
|
|
97
|
-
|
|
98
|
-
if (write) {
|
|
99
|
-
const ogImageLine = `ogImage: ${relativePath}`;
|
|
100
|
-
|
|
101
|
-
let newContent: string;
|
|
102
|
-
if (data.ogImage) {
|
|
103
|
-
newContent = content.replace(/^ogImage:.*$/m, ogImageLine);
|
|
104
|
-
} else {
|
|
105
|
-
const lines = content.split("\n");
|
|
106
|
-
const closingIdx = lines.indexOf("---", 1);
|
|
107
|
-
if (closingIdx > 0) {
|
|
108
|
-
lines.splice(closingIdx, 0, ogImageLine);
|
|
109
|
-
}
|
|
110
|
-
newContent = lines.join("\n");
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
await writeFile(fullPath, newContent, "utf-8");
|
|
114
|
-
console.log(` ✍️ 已更新 frontmatter ogImage 字段`);
|
|
115
|
-
} else {
|
|
116
|
-
console.log(`\n💡 添加到 frontmatter:\n ogImage: ${relativePath}`);
|
|
117
|
-
}
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
main().catch(err => {
|
|
121
|
-
console.error("❌ 错误:", err.message || err);
|
|
122
|
-
process.exit(1);
|
|
123
|
-
});
|
|
@@ -1,280 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env npx tsx
|
|
2
|
-
/* eslint-disable no-console */
|
|
3
|
-
|
|
4
|
-
import { mkdir } from "node:fs/promises";
|
|
5
|
-
import path from "node:path";
|
|
6
|
-
import { spawn, execSync } from "node:child_process";
|
|
7
|
-
import sharp from "sharp";
|
|
8
|
-
|
|
9
|
-
const rootDir = process.cwd();
|
|
10
|
-
const inputDir = path.join(rootDir, ".codex-temp", "og");
|
|
11
|
-
const outputJpg = path.join(rootDir, "public", "astro-minimax-og.jpg");
|
|
12
|
-
const outputPng = path.join(rootDir, ".codex-temp", "og-result.png");
|
|
13
|
-
|
|
14
|
-
// ─── 画布 ────────────────────────────────────────────────────────────────────
|
|
15
|
-
const outputWidth = 2400;
|
|
16
|
-
const outputHeight = 1260;
|
|
17
|
-
|
|
18
|
-
// ─── 旋转角度(负 = 逆时针倾斜) ─────────────────────────────────────────────
|
|
19
|
-
const angleDeg = -32;
|
|
20
|
-
|
|
21
|
-
// ─── 卡片内容区尺寸(更大,以便人眼能看清内容)──────────────────────────────
|
|
22
|
-
const cardWidth = 840;
|
|
23
|
-
const cardHeight = 526;
|
|
24
|
-
|
|
25
|
-
// ─── 卡片帧留白(内容区四周的空白,让卡片之间有视觉间隙)────────────────────
|
|
26
|
-
// SVG 阴影扩散需要这块空间,同时提供"上下左右间距"
|
|
27
|
-
const cardPad = 36;
|
|
28
|
-
|
|
29
|
-
// ─── 网格参数(正交坐标系,旋转前) ─────────────────────────────────────────
|
|
30
|
-
const cols = 4;
|
|
31
|
-
const rows = 3;
|
|
32
|
-
|
|
33
|
-
// 列中心点的水平间距 = 卡片宽 + 列间隙
|
|
34
|
-
const colSpacing = cardWidth + cardPad * 2 + 80; // ≈ 1048
|
|
35
|
-
|
|
36
|
-
// 行中心点的垂直间距 = 卡片高 + 行间隙
|
|
37
|
-
// !! 关键修复:必须 > cardHeight,否则卡片在旋转前就重叠
|
|
38
|
-
const rowSpacing = cardHeight + cardPad * 2 + 72; // ≈ 670
|
|
39
|
-
|
|
40
|
-
// ─── 截图配置 ─────────────────────────────────────────────────────────────────
|
|
41
|
-
const pages = [
|
|
42
|
-
{ url: "/zh/", filename: "home-light.png", theme: "light" },
|
|
43
|
-
{ url: "/zh/", filename: "home-dark.png", theme: "dark" },
|
|
44
|
-
{ url: "/zh/categories/", filename: "categories-light.png", theme: "light" },
|
|
45
|
-
{ url: "/zh/posts/", filename: "posts-light.png", theme: "light" },
|
|
46
|
-
{ url: "/zh/tags/", filename: "tags-light.png", theme: "light" },
|
|
47
|
-
{ url: "/zh/search/", filename: "search-light.png", theme: "light" },
|
|
48
|
-
{ url: "/zh/friends/", filename: "friends-light.png", theme: "light" },
|
|
49
|
-
{ url: "/zh/about/", filename: "about-light.png", theme: "light" },
|
|
50
|
-
{ url: "/zh/posts/dynamic-og-images/", filename: "post-light.png", theme: "light" },
|
|
51
|
-
{ url: "/zh/archives/", filename: "archives-light.png", theme: "light" },
|
|
52
|
-
{ url: "/zh/", filename: "home-light2.png", theme: "light" },
|
|
53
|
-
{ url: "/zh/posts/", filename: "posts-dark.png", theme: "dark" },
|
|
54
|
-
];
|
|
55
|
-
|
|
56
|
-
// ─── 布局:gridLayout[行][列] = 文件名 ───────────────────────────────────────
|
|
57
|
-
const gridLayout: (string | null)[][] = [
|
|
58
|
-
["home-dark.png", "posts-light.png", "tags-light.png", "about-light.png" ],
|
|
59
|
-
["home-light.png", "categories-light.png","search-light.png", "post-light.png" ],
|
|
60
|
-
["friends-light.png","archives-light.png", "home-light2.png", "posts-dark.png" ],
|
|
61
|
-
];
|
|
62
|
-
|
|
63
|
-
// ─── 旋转坐标(将正交网格坐标旋转 angleDeg)─────────────────────────────────
|
|
64
|
-
// !! 关键:必须同时旋转"坐标"和"卡片本身",两者角度保持一致,
|
|
65
|
-
// 才能让同一列的卡片在旋转后依然垂直对齐(沿倾斜方向排成一列)
|
|
66
|
-
function rotatePoint(x: number, y: number) {
|
|
67
|
-
const rad = (angleDeg * Math.PI) / 180;
|
|
68
|
-
return {
|
|
69
|
-
x: x * Math.cos(rad) - y * Math.sin(rad),
|
|
70
|
-
y: x * Math.sin(rad) + y * Math.cos(rad),
|
|
71
|
-
};
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
// ─── 背景 SVG ─────────────────────────────────────────────────────────────────
|
|
75
|
-
function backgroundSvg(w: number, h: number) {
|
|
76
|
-
return `<svg width="${w}" height="${h}" viewBox="0 0 ${w} ${h}" xmlns="http://www.w3.org/2000/svg">
|
|
77
|
-
<defs>
|
|
78
|
-
<linearGradient id="bg" x1="0" y1="0" x2="${w}" y2="${h}" gradientUnits="userSpaceOnUse">
|
|
79
|
-
<stop stop-color="#E8ECF2"/>
|
|
80
|
-
<stop offset="0.5" stop-color="#DEE5EE"/>
|
|
81
|
-
<stop offset="1" stop-color="#D5DDE8"/>
|
|
82
|
-
</linearGradient>
|
|
83
|
-
<filter id="glow" x="-50%" y="-50%" width="200%" height="200%">
|
|
84
|
-
<feGaussianBlur stdDeviation="32"/>
|
|
85
|
-
</filter>
|
|
86
|
-
</defs>
|
|
87
|
-
<rect width="${w}" height="${h}" fill="url(#bg)"/>
|
|
88
|
-
<g opacity="0.78" filter="url(#glow)">
|
|
89
|
-
<rect x="100" y="-900" width="180" height="3200" rx="90" transform="rotate(45 240 660)" fill="#FFFFFF"/>
|
|
90
|
-
<rect x="980" y="-940" width="196" height="3300" rx="98" transform="rotate(45 1060 655)" fill="#F6FAFD"/>
|
|
91
|
-
<rect x="1860" y="-910" width="180" height="3240" rx="90" transform="rotate(45 1880 650)" fill="#FFFFFF"/>
|
|
92
|
-
</g>
|
|
93
|
-
</svg>`;
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
// ─── 卡片 SVG(圆角 + 阴影 + 内容图像) ─────────────────────────────────────
|
|
97
|
-
function cardSvg(imageBase64: string) {
|
|
98
|
-
const p = cardPad;
|
|
99
|
-
const r = 20;
|
|
100
|
-
const fw = cardWidth + p * 2;
|
|
101
|
-
const fh = cardHeight + p * 2;
|
|
102
|
-
|
|
103
|
-
return `<svg width="${fw}" height="${fh}" viewBox="0 0 ${fw} ${fh}" xmlns="http://www.w3.org/2000/svg">
|
|
104
|
-
<defs>
|
|
105
|
-
<clipPath id="clip">
|
|
106
|
-
<rect x="${p}" y="${p}" width="${cardWidth}" height="${cardHeight}" rx="${r}"/>
|
|
107
|
-
</clipPath>
|
|
108
|
-
<filter id="shadow" x="-30%" y="-30%" width="160%" height="190%">
|
|
109
|
-
<feDropShadow dx="0" dy="18" stdDeviation="16" flood-color="#1A2840" flood-opacity="0.16"/>
|
|
110
|
-
</filter>
|
|
111
|
-
</defs>
|
|
112
|
-
<g filter="url(#shadow)">
|
|
113
|
-
<rect x="${p}" y="${p}" width="${cardWidth}" height="${cardHeight}" rx="${r}" fill="#FAFAF8"/>
|
|
114
|
-
<image
|
|
115
|
-
href="data:image/png;base64,${imageBase64}"
|
|
116
|
-
x="${p}" y="${p}" width="${cardWidth}" height="${cardHeight}"
|
|
117
|
-
preserveAspectRatio="xMidYMid slice"
|
|
118
|
-
clip-path="url(#clip)"/>
|
|
119
|
-
<rect x="${p}" y="${p}" width="${cardWidth}" height="${cardHeight}"
|
|
120
|
-
rx="${r}" fill="none" stroke="#D4DCE7" stroke-width="1.5"/>
|
|
121
|
-
</g>
|
|
122
|
-
</svg>`;
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
// ─── 渲染单张卡片(裁剪 → 帧 → 旋转) ───────────────────────────────────────
|
|
126
|
-
async function renderCard(filename: string) {
|
|
127
|
-
const isDark = filename.includes("dark");
|
|
128
|
-
const gravity = filename.includes("friends") ? "center" : "north";
|
|
129
|
-
const brightness = isDark ? 1.07 : 1.03;
|
|
130
|
-
const saturation = isDark ? 1.12 : 1.06;
|
|
131
|
-
|
|
132
|
-
// 1. 截图裁剪到卡片内容区
|
|
133
|
-
const cropped = await sharp(path.join(inputDir, filename))
|
|
134
|
-
.resize(cardWidth, cardHeight, { fit: "cover", position: gravity })
|
|
135
|
-
.modulate({ brightness, saturation })
|
|
136
|
-
.sharpen()
|
|
137
|
-
.png()
|
|
138
|
-
.toBuffer();
|
|
139
|
-
|
|
140
|
-
// 2. 合成卡片帧(圆角 + 阴影 + 边框 + padding)
|
|
141
|
-
const framed = await sharp(Buffer.from(cardSvg(cropped.toString("base64"))))
|
|
142
|
-
.png()
|
|
143
|
-
.toBuffer();
|
|
144
|
-
|
|
145
|
-
// 3. 旋转卡片本身(透明背景)
|
|
146
|
-
// !! 与 rotatePoint() 使用同一角度,保证列内对齐
|
|
147
|
-
const rotated = await sharp(framed)
|
|
148
|
-
.rotate(angleDeg, { background: { r: 0, g: 0, b: 0, alpha: 0 } })
|
|
149
|
-
.png()
|
|
150
|
-
.toBuffer();
|
|
151
|
-
|
|
152
|
-
const { width = 0, height = 0 } = await sharp(rotated).metadata();
|
|
153
|
-
return { input: rotated, width, height };
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
// ─── 启动预览服务器 ────────────────────────────────────────────────────────────
|
|
157
|
-
function startPreviewServer(): Promise<{ url: string; kill: () => void }> {
|
|
158
|
-
return new Promise((resolve, reject) => {
|
|
159
|
-
console.log("🚀 Starting preview server...");
|
|
160
|
-
const server = spawn("pnpm", ["run", "preview"], {
|
|
161
|
-
cwd: rootDir, stdio: ["ignore", "pipe", "pipe"], detached: false,
|
|
162
|
-
});
|
|
163
|
-
let url = "";
|
|
164
|
-
server.stdout.on("data", (data) => {
|
|
165
|
-
const out = data.toString();
|
|
166
|
-
console.log(out);
|
|
167
|
-
const m = out.match(/localhost:(\d+)/);
|
|
168
|
-
if (m && !url) {
|
|
169
|
-
url = `http://localhost:${m[1]}`;
|
|
170
|
-
console.log(`✅ Server ready at ${url}`);
|
|
171
|
-
setTimeout(() => resolve({ url, kill: () => server.kill("SIGTERM") }), 2000);
|
|
172
|
-
}
|
|
173
|
-
});
|
|
174
|
-
server.stderr.on("data", (d) => {
|
|
175
|
-
const s = d.toString();
|
|
176
|
-
if (!s.includes("Prebundling") && !s.includes("hmr")) console.error(s);
|
|
177
|
-
});
|
|
178
|
-
server.on("error", reject);
|
|
179
|
-
setTimeout(() => { if (!url) reject(new Error("Server startup timeout")); }, 30000);
|
|
180
|
-
});
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
// ─── 截图 ─────────────────────────────────────────────────────────────────────
|
|
184
|
-
async function captureScreenshots(baseUrl: string) {
|
|
185
|
-
console.log("\n📸 Starting screenshot capture...");
|
|
186
|
-
await mkdir(inputDir, { recursive: true });
|
|
187
|
-
|
|
188
|
-
const puppeteer = await import("puppeteer-core");
|
|
189
|
-
const chromePaths = [
|
|
190
|
-
"/opt/google/chrome/chrome", "/usr/bin/google-chrome",
|
|
191
|
-
"/usr/bin/chromium", "/usr/bin/chromium-browser",
|
|
192
|
-
];
|
|
193
|
-
let chromePath = "";
|
|
194
|
-
for (const p of chromePaths) {
|
|
195
|
-
try { execSync(`ls -f ${p}`, { stdio: "ignore" }); chromePath = p; break; }
|
|
196
|
-
catch { /* try next */ }
|
|
197
|
-
}
|
|
198
|
-
if (!chromePath) { console.error("Chrome not found"); process.exit(1); }
|
|
199
|
-
|
|
200
|
-
const browser = await puppeteer.default.launch({
|
|
201
|
-
executablePath: chromePath, headless: true,
|
|
202
|
-
args: ["--no-sandbox", "--disable-setuid-sandbox"],
|
|
203
|
-
});
|
|
204
|
-
|
|
205
|
-
try {
|
|
206
|
-
for (const cfg of pages) {
|
|
207
|
-
console.log(` Capturing: ${cfg.filename}`);
|
|
208
|
-
const page = await browser.newPage();
|
|
209
|
-
await page.setViewport({ width: 1920, height: 1080 });
|
|
210
|
-
await page.emulateMediaFeatures([{ name: "prefers-color-scheme", value: cfg.theme }]);
|
|
211
|
-
await page.goto(`${baseUrl}${cfg.url}`, { waitUntil: "networkidle0", timeout: 30000 });
|
|
212
|
-
await new Promise(r => setTimeout(r, 1000));
|
|
213
|
-
await page.screenshot({
|
|
214
|
-
path: path.join(inputDir, cfg.filename) as `${string}.png`, type: "png",
|
|
215
|
-
});
|
|
216
|
-
console.log(` ✅ ${cfg.filename}`);
|
|
217
|
-
await page.close();
|
|
218
|
-
}
|
|
219
|
-
} finally {
|
|
220
|
-
await browser.close();
|
|
221
|
-
}
|
|
222
|
-
console.log("✅ All screenshots captured!");
|
|
223
|
-
}
|
|
224
|
-
|
|
225
|
-
// ─── 主流程 ───────────────────────────────────────────────────────────────────
|
|
226
|
-
async function main() {
|
|
227
|
-
await mkdir(path.join(rootDir, ".codex-temp"), { recursive: true });
|
|
228
|
-
const server = await startPreviewServer();
|
|
229
|
-
|
|
230
|
-
try {
|
|
231
|
-
await captureScreenshots(server.url);
|
|
232
|
-
|
|
233
|
-
console.log("\n🎨 Compositing OG image...");
|
|
234
|
-
|
|
235
|
-
const cx = outputWidth / 2; // 1200
|
|
236
|
-
const cy = outputHeight / 2; // 630
|
|
237
|
-
const composites: sharp.OverlayOptions[] = [];
|
|
238
|
-
|
|
239
|
-
for (let col = 0; col < cols; col++) {
|
|
240
|
-
// ── Step 1:确定列中心 X(正交坐标系,旋转前)────────────────────────
|
|
241
|
-
const gridX = (col - (cols - 1) / 2) * colSpacing;
|
|
242
|
-
|
|
243
|
-
for (let row = 0; row < rows; row++) {
|
|
244
|
-
const filename = gridLayout[row]?.[col];
|
|
245
|
-
if (!filename) continue;
|
|
246
|
-
|
|
247
|
-
// ── Step 2:确定行中心 Y(正交坐标系,旋转前)─────────────────────
|
|
248
|
-
// 同一列所有卡片的 gridX 完全相同 → 旋转后沿倾斜方向对齐
|
|
249
|
-
const gridY = (row - (rows - 1) / 2) * rowSpacing;
|
|
250
|
-
|
|
251
|
-
// ── Step 3:旋转坐标到最终画布坐标 ───────────────────────────────
|
|
252
|
-
const pos = rotatePoint(gridX, gridY);
|
|
253
|
-
|
|
254
|
-
// ── Step 4:渲染(卡片本身也旋转相同角度)─────────────────────────
|
|
255
|
-
const card = await renderCard(filename);
|
|
256
|
-
|
|
257
|
-
composites.push({
|
|
258
|
-
input: card.input,
|
|
259
|
-
left: Math.round(cx + pos.x - card.width / 2),
|
|
260
|
-
top: Math.round(cy + pos.y - card.height / 2),
|
|
261
|
-
});
|
|
262
|
-
}
|
|
263
|
-
}
|
|
264
|
-
|
|
265
|
-
const bg = Buffer.from(backgroundSvg(outputWidth, outputHeight));
|
|
266
|
-
const result = sharp(bg).composite(composites);
|
|
267
|
-
|
|
268
|
-
await result.clone().png().toFile(outputPng);
|
|
269
|
-
await result.clone().jpeg({ quality: 93, mozjpeg: true }).toFile(outputJpg);
|
|
270
|
-
|
|
271
|
-
console.log(`\n✅ Done!`);
|
|
272
|
-
console.log(` PNG → ${outputPng}`);
|
|
273
|
-
console.log(` JPG → ${outputJpg}`);
|
|
274
|
-
} finally {
|
|
275
|
-
console.log("\n🛑 Stopping server...");
|
|
276
|
-
server.kill();
|
|
277
|
-
}
|
|
278
|
-
}
|
|
279
|
-
|
|
280
|
-
main().then(() => process.exit(0)).catch(e => { console.error(e); process.exit(1); });
|
|
@@ -1,146 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env npx tsx
|
|
2
|
-
/**
|
|
3
|
-
* 关联文章推荐工具
|
|
4
|
-
*
|
|
5
|
-
* 用法:
|
|
6
|
-
* pnpm run tools:generate-related # 基于标签/标题的关联推荐
|
|
7
|
-
* pnpm run tools:generate-related --ai # 使用 AI 语义分析(需要向量索引)
|
|
8
|
-
* pnpm run tools:generate-related --verbose # 显示详细评分
|
|
9
|
-
*/
|
|
10
|
-
|
|
11
|
-
import { readFile } from "node:fs/promises";
|
|
12
|
-
import { join } from "node:path";
|
|
13
|
-
import { getAllPosts, type PostMeta } from "./lib/posts.js";
|
|
14
|
-
import { cosineSimilarity, type VectorIndex } from "./lib/vectors.js";
|
|
15
|
-
|
|
16
|
-
const VECTOR_INDEX = join(process.cwd(), "src/data/vectors/index.json");
|
|
17
|
-
|
|
18
|
-
function tagSimilarity(a: PostMeta, b: PostMeta): number {
|
|
19
|
-
let score = 0;
|
|
20
|
-
const aTags = new Set(a.tags.map(t => t.toLowerCase()));
|
|
21
|
-
const bTags = new Set(b.tags.map(t => t.toLowerCase()));
|
|
22
|
-
|
|
23
|
-
for (const tag of aTags) {
|
|
24
|
-
if (bTags.has(tag)) score += 3;
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
if (a.category && b.category) {
|
|
28
|
-
if (a.category === b.category) score += 5;
|
|
29
|
-
else {
|
|
30
|
-
const ap = a.category.split("/");
|
|
31
|
-
const bp = b.category.split("/");
|
|
32
|
-
if (ap[0] === bp[0]) score += 2;
|
|
33
|
-
}
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
const aWords = new Set(
|
|
37
|
-
a.title
|
|
38
|
-
.toLowerCase()
|
|
39
|
-
.split(/\s+/)
|
|
40
|
-
.filter(w => w.length > 2)
|
|
41
|
-
);
|
|
42
|
-
const bWords = new Set(
|
|
43
|
-
b.title
|
|
44
|
-
.toLowerCase()
|
|
45
|
-
.split(/\s+/)
|
|
46
|
-
.filter(w => w.length > 2)
|
|
47
|
-
);
|
|
48
|
-
for (const w of aWords) {
|
|
49
|
-
if (bWords.has(w)) score += 1;
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
return score;
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
async function loadVectorIndex(): Promise<VectorIndex | null> {
|
|
56
|
-
try {
|
|
57
|
-
const content = await readFile(VECTOR_INDEX, "utf-8");
|
|
58
|
-
return JSON.parse(content) as VectorIndex;
|
|
59
|
-
} catch {
|
|
60
|
-
return null;
|
|
61
|
-
}
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
function vectorSimilarity(
|
|
65
|
-
postIdA: string,
|
|
66
|
-
postIdB: string,
|
|
67
|
-
index: VectorIndex
|
|
68
|
-
): number {
|
|
69
|
-
const chunksA = index.chunks.filter(c => c.postId === postIdA && c.vector);
|
|
70
|
-
const chunksB = index.chunks.filter(c => c.postId === postIdB && c.vector);
|
|
71
|
-
|
|
72
|
-
if (chunksA.length === 0 || chunksB.length === 0) return 0;
|
|
73
|
-
|
|
74
|
-
let maxSim = 0;
|
|
75
|
-
for (const ca of chunksA) {
|
|
76
|
-
for (const cb of chunksB) {
|
|
77
|
-
const sim = cosineSimilarity(ca.vector!, cb.vector!);
|
|
78
|
-
if (sim > maxSim) maxSim = sim;
|
|
79
|
-
}
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
return maxSim;
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
async function main() {
|
|
86
|
-
const args = process.argv.slice(2);
|
|
87
|
-
const useAI = args.includes("--ai");
|
|
88
|
-
const verbose = args.includes("--verbose");
|
|
89
|
-
|
|
90
|
-
const posts = await getAllPosts();
|
|
91
|
-
console.log(`📚 共 ${posts.length} 篇文章\n`);
|
|
92
|
-
|
|
93
|
-
let vectorIndex: VectorIndex | null = null;
|
|
94
|
-
if (useAI) {
|
|
95
|
-
vectorIndex = await loadVectorIndex();
|
|
96
|
-
if (!vectorIndex) {
|
|
97
|
-
console.error("❌ 向量索引未找到。请先运行: pnpm run tools:vectorize");
|
|
98
|
-
process.exit(1);
|
|
99
|
-
}
|
|
100
|
-
console.log(
|
|
101
|
-
`🧠 使用向量索引(${vectorIndex.method} 模式,${vectorIndex.chunks.length} 个块)\n`
|
|
102
|
-
);
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
const zhPosts = posts.filter(p => p.lang === "zh");
|
|
106
|
-
const enPosts = posts.filter(p => p.lang === "en");
|
|
107
|
-
|
|
108
|
-
for (const langPosts of [zhPosts, enPosts]) {
|
|
109
|
-
if (langPosts.length === 0) continue;
|
|
110
|
-
const langLabel = langPosts[0].lang === "zh" ? "中文" : "English";
|
|
111
|
-
console.log(`\n=== ${langLabel} (${langPosts.length} 篇) ===\n`);
|
|
112
|
-
|
|
113
|
-
for (const post of langPosts) {
|
|
114
|
-
const scores = langPosts
|
|
115
|
-
.filter(p => p.id !== post.id)
|
|
116
|
-
.map(p => {
|
|
117
|
-
let score = tagSimilarity(post, p);
|
|
118
|
-
|
|
119
|
-
if (useAI && vectorIndex) {
|
|
120
|
-
const vecSim = vectorSimilarity(post.id, p.id, vectorIndex);
|
|
121
|
-
score += vecSim * 10;
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
return { post: p, score };
|
|
125
|
-
})
|
|
126
|
-
.sort((a, b) => b.score - a.score)
|
|
127
|
-
.slice(0, 3);
|
|
128
|
-
|
|
129
|
-
console.log(`📝 【${post.title}】`);
|
|
130
|
-
if (scores.length > 0) {
|
|
131
|
-
console.log(" 推荐关联:");
|
|
132
|
-
for (const s of scores) {
|
|
133
|
-
const scoreStr = verbose
|
|
134
|
-
? ` (得分: ${s.score.toFixed(2)})`
|
|
135
|
-
: "";
|
|
136
|
-
console.log(` - ${s.post.title}${scoreStr}`);
|
|
137
|
-
}
|
|
138
|
-
} else {
|
|
139
|
-
console.log(" (无匹配)");
|
|
140
|
-
}
|
|
141
|
-
console.log("");
|
|
142
|
-
}
|
|
143
|
-
}
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
main().catch(console.error);
|