Compare commits

..

7 Commits

Author SHA1 Message Date
Jim Liu 宝玉 7d03685ade chore: release v1.37.1 2026-02-27 18:35:25 -06:00
Jim Liu 宝玉 b305c386bc chore: remove unused Gemini model comparison images
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 18:32:05 -06:00
Jim Liu 宝玉 240dd7d314 Merge pull request #56 from xkcoding/fix/update-gemini-web-model-headers
fix(baoyu-danger-gemini-web): sync model headers with upstream and update model list
2026-02-27 18:30:48 -06:00
Jim Liu 宝玉 b02ceacfd9 chore: release v1.37.0 2026-02-27 18:29:04 -06:00
Jim Liu 宝玉 fdf9007e2c feat(baoyu-danger-x-to-markdown): improve article rendering with inline links and content-based output paths 2026-02-27 18:28:34 -06:00
Yangkai.Shen e737c4a611 docs: add model comparison images for PR #56
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 19:59:42 +08:00
Yangkai.Shen 2eec4f3639 fix(baoyu-danger-gemini-web): sync model headers with upstream and update model list
- Update x-goog-ext-525001261-jspb header format to match upstream
  HanaokaYuzu/Gemini-API (commit 42900f7, 2026-02-03), appending
  `,null,null,1` to fix image generation failures
- Replace deprecated gemini-2.5-pro and gemini-2.5-flash with
  gemini-3.0-flash and gemini-3.0-flash-thinking
- Add gemini-3.1-pro-preview (empty header, server auto-routed)
- Update SKILL.md and CLI help text to reflect new model options

Closes #50

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 19:40:50 +08:00
8 changed files with 196 additions and 56 deletions
+1 -1
View File
@@ -6,7 +6,7 @@
},
"metadata": {
"description": "Skills shared by Baoyu for improving daily work efficiency",
"version": "1.36.0"
"version": "1.37.1"
},
"plugins": [
{
+12
View File
@@ -2,6 +2,18 @@
English | [中文](./CHANGELOG.zh.md)
## 1.37.1 - 2026-02-27
### Fixes
- `baoyu-danger-gemini-web`: sync model headers with upstream and update model list (by @xkcoding)
## 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
### Features
+12
View File
@@ -2,6 +2,18 @@
[English](./CHANGELOG.md) | 中文
## 1.37.1 - 2026-02-27
### 修复
- `baoyu-danger-gemini-web`:同步上游模型请求头并更新模型列表 (by @xkcoding)
## 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
### 新功能
+6 -5
View File
@@ -76,7 +76,7 @@ test -f "$HOME/.baoyu-skills/baoyu-danger-gemini-web/EXTEND.md" && echo "user"
```bash
# Text generation
npx -y bun ${SKILL_DIR}/scripts/main.ts "Your prompt"
npx -y bun ${SKILL_DIR}/scripts/main.ts --prompt "Your prompt" --model gemini-2.5-pro
npx -y bun ${SKILL_DIR}/scripts/main.ts --prompt "Your prompt" --model gemini-3-flash
# Image generation
npx -y bun ${SKILL_DIR}/scripts/main.ts --prompt "A cute cat" --image cat.png
@@ -100,7 +100,7 @@ npx -y bun ${SKILL_DIR}/scripts/main.ts "Hello" --json
|--------|-------------|
| `--prompt`, `-p` | Prompt text |
| `--promptfiles` | Read prompt from files (concatenated) |
| `--model`, `-m` | Model: gemini-3-pro (default), gemini-2.5-pro, gemini-2.5-flash |
| `--model`, `-m` | Model: gemini-3-pro (default), gemini-3-flash, gemini-3-flash-thinking, gemini-3.1-pro-preview |
| `--image [path]` | Generate image (default: generated.png) |
| `--reference`, `--ref` | Reference images for vision input |
| `--sessionId` | Session ID for multi-turn conversation |
@@ -114,9 +114,10 @@ npx -y bun ${SKILL_DIR}/scripts/main.ts "Hello" --json
| Model | Description |
|-------|-------------|
| `gemini-3-pro` | Default, latest |
| `gemini-2.5-pro` | Previous pro |
| `gemini-2.5-flash` | Fast, lightweight |
| `gemini-3-pro` | Default, latest 3.0 Pro |
| `gemini-3-flash` | Fast, lightweight 3.0 Flash |
| `gemini-3-flash-thinking` | 3.0 Flash with thinking |
| `gemini-3.1-pro-preview` | 3.1 Pro preview (empty header, auto-routed) |
## Authentication
@@ -47,17 +47,22 @@ export class Model {
static readonly UNSPECIFIED = new Model('unspecified', {}, false);
static readonly G_3_0_PRO = new Model(
'gemini-3.0-pro',
{ 'x-goog-ext-525001261-jspb': '[1,null,null,null,"9d8ca3786ebdfbea",null,null,0,[4]]' },
{ 'x-goog-ext-525001261-jspb': '[1,null,null,null,"9d8ca3786ebdfbea",null,null,0,[4],null,null,1]' },
false,
);
static readonly G_2_5_PRO = new Model(
'gemini-2.5-pro',
{ 'x-goog-ext-525001261-jspb': '[1,null,null,null,"4af6c7f5da75d65d",null,null,0,[4]]' },
static readonly G_3_0_FLASH = new Model(
'gemini-3.0-flash',
{ 'x-goog-ext-525001261-jspb': '[1,null,null,null,"fbb127bbb056c959",null,null,0,[4],null,null,1]' },
false,
);
static readonly G_2_5_FLASH = new Model(
'gemini-2.5-flash',
{ 'x-goog-ext-525001261-jspb': '[1,null,null,null,"9ec249fc9ad08861",null,null,0,[4]]' },
static readonly G_3_0_FLASH_THINKING = new Model(
'gemini-3.0-flash-thinking',
{ 'x-goog-ext-525001261-jspb': '[1,null,null,null,"5bf011840784117a",null,null,0,[4],null,null,1]' },
false,
);
static readonly G_3_1_PRO_PREVIEW = new Model(
'gemini-3.1-pro-preview',
{},
false,
);
@@ -68,12 +73,12 @@ export class Model {
) {}
static from_name(name: string): Model {
for (const model of [Model.UNSPECIFIED, Model.G_3_0_PRO, Model.G_2_5_PRO, Model.G_2_5_FLASH]) {
for (const model of [Model.UNSPECIFIED, Model.G_3_0_PRO, Model.G_3_0_FLASH, Model.G_3_0_FLASH_THINKING, Model.G_3_1_PRO_PREVIEW]) {
if (model.model_name === name) return model;
}
throw new Error(
`Unknown model name: ${name}. Available models: ${[Model.UNSPECIFIED, Model.G_3_0_PRO, Model.G_2_5_PRO, Model.G_2_5_FLASH]
`Unknown model name: ${name}. Available models: ${[Model.UNSPECIFIED, Model.G_3_0_PRO, Model.G_3_0_FLASH, Model.G_3_0_FLASH_THINKING, Model.G_3_1_PRO_PREVIEW]
.map((m) => m.model_name)
.join(', ')}`,
);
@@ -73,7 +73,7 @@ Multi-turn conversation (agent generates unique sessionId):
Options:
-p, --prompt <text> Prompt text
--promptfiles <files...> Read prompt from one or more files (concatenated in order)
-m, --model <id> gemini-3-pro | gemini-2.5-pro | gemini-2.5-flash (default: gemini-3-pro)
-m, --model <id> gemini-3-pro | gemini-3-flash | gemini-3-flash-thinking | gemini-3.1-pro-preview (default: gemini-3-pro)
--json Output JSON
--image [path] Generate an image and save it (default: ./generated.png)
--reference <files...> Reference images for vision input
@@ -227,8 +227,11 @@ function resolveModel(id: string): Model {
const k = id.trim();
if (k === 'gemini-3-pro') return Model.G_3_0_PRO;
if (k === 'gemini-3.0-pro') return Model.G_3_0_PRO;
if (k === 'gemini-2.5-pro') return Model.G_2_5_PRO;
if (k === 'gemini-2.5-flash') return Model.G_2_5_FLASH;
if (k === 'gemini-3-flash') return Model.G_3_0_FLASH;
if (k === 'gemini-3.0-flash') return Model.G_3_0_FLASH;
if (k === 'gemini-3-flash-thinking') return Model.G_3_0_FLASH_THINKING;
if (k === 'gemini-3.0-flash-thinking') return Model.G_3_0_FLASH_THINKING;
if (k === 'gemini-3.1-pro-preview') return Model.G_3_1_PRO_PREVIEW;
return Model.from_name(k);
}
@@ -2,7 +2,7 @@ import fs from "node:fs";
import path from "node:path";
import readline from "node:readline";
import process from "node:process";
import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
import { mkdir, readFile, writeFile } from "node:fs/promises";
import { fetchXArticle } from "./graphql.js";
import { formatArticleMarkdown } from "./markdown.js";
@@ -182,36 +182,32 @@ function sanitizeSlug(input: string): string {
.slice(0, 120);
}
function formatBackupTimestamp(date: Date = new Date()): string {
const pad2 = (n: number) => String(n).padStart(2, "0");
return `${date.getFullYear()}${pad2(date.getMonth() + 1)}${pad2(date.getDate())}-${pad2(date.getHours())}${pad2(
date.getMinutes()
)}${pad2(date.getSeconds())}`;
}
async function backupDirIfExists(dir: string, log: (message: string) => void): Promise<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 ?? "")}`
);
function extractContentSlug(markdown: string): string {
const headingMatch = markdown.match(/^#\s+(.+)$/m);
if (headingMatch?.[1]) {
return sanitizeSlug(headingMatch[1].slice(0, 60)).toLowerCase();
}
}
function resolveDefaultOutputDir(slug: string): string {
return path.resolve(process.cwd(), "x-to-markdown", slug);
const lines = markdown.split("\n");
let inFrontmatter = false;
for (const line of lines) {
if (line === "---") {
inFrontmatter = !inFrontmatter;
continue;
}
if (inFrontmatter) continue;
const trimmed = line.trim();
if (trimmed) {
return sanitizeSlug(trimmed.slice(0, 60)).toLowerCase();
}
}
return "untitled";
}
async function resolveOutputPath(
normalizedUrl: string,
kind: "tweet" | "article",
argsOutput: string | null,
contentSlug: string,
log: (message: string) => void
): Promise<{ outputDir: string; markdownPath: string; slug: string }> {
const articleId = kind === "article" ? parseArticleId(normalizedUrl) : null;
@@ -222,15 +218,14 @@ async function resolveOutputPath(
const idPart = articleId ?? tweetId ?? String(Date.now());
const slug = userSlug ?? idPart;
const defaultFileName = kind === "article" ? `${idPart}.md` : `${idPart}.md`;
const defaultFileName = `${idPart}.md`;
if (argsOutput) {
const wantsDir = argsOutput.endsWith("/") || argsOutput.endsWith("\\");
const resolved = path.resolve(argsOutput);
try {
if (wantsDir || (fs.existsSync(resolved) && fs.statSync(resolved).isDirectory())) {
const outputDir = path.join(resolved, slug);
await backupDirIfExists(outputDir, log);
const outputDir = path.join(resolved, slug, contentSlug);
await mkdir(outputDir, { recursive: true });
return { outputDir, markdownPath: path.join(outputDir, defaultFileName), slug };
}
@@ -243,8 +238,7 @@ async function resolveOutputPath(
return { outputDir, markdownPath: resolved, slug };
}
const outputDir = resolveDefaultOutputDir(slug);
await backupDirIfExists(outputDir, log);
const outputDir = path.resolve(process.cwd(), "x-to-markdown", slug, contentSlug);
await mkdir(outputDir, { recursive: true });
return { outputDir, markdownPath: path.join(outputDir, defaultFileName), slug };
}
@@ -394,13 +388,15 @@ async function main(): Promise<void> {
}
const kind = articleId ? ("article" as const) : ("tweet" as const);
const { outputDir, markdownPath, slug } = await resolveOutputPath(normalizedUrl, kind, args.output, log);
let markdown =
kind === "article" && articleId
? await convertArticleToMarkdown(normalizedUrl, articleId, log)
: await tweetToMarkdown(normalizedUrl, { log });
const contentSlug = extractContentSlug(markdown);
const { outputDir, markdownPath, slug } = await resolveOutputPath(normalizedUrl, kind, args.output, contentSlug, log);
let mediaResult: LocalizeMarkdownMediaResult | null = null;
if (args.downloadMedia) {
@@ -117,11 +117,114 @@ function resolveEntityMediaLines(
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(
blocks: ArticleBlock[],
entityMap: ArticleContentState["entityMap"] | undefined,
mediaById: Map<string, string>,
usedUrls: Set<string>
usedUrls: Set<string>,
atomicMediaQueue: string[],
mediaLinkMap: Map<number, string>
): string[] {
const lines: string[] = [];
let previousKind: "list" | "quote" | "heading" | "text" | "code" | "media" | null = null;
@@ -157,7 +260,12 @@ function renderContentBlocks(
for (const block of blocks) {
const type = typeof block?.type === "string" ? block.type : "unstyled";
const text = typeof block?.text === "string" ? block.text : "";
const rawText = typeof block?.text === "string" ? block.text : "";
const ranges = Array.isArray(block.entityRanges) ? block.entityRanges : [];
const text =
type !== "atomic" && type !== "code-block"
? renderInlineLinks(rawText, ranges, entityMap, mediaLinkMap)
: rawText;
if (type === "code-block") {
if (!inCodeBlock) {
@@ -185,6 +293,12 @@ function renderContentBlocks(
const mediaLines = collectMediaLines(block);
if (mediaLines.length > 0) {
pushBlock(mediaLines, "media");
} else if (atomicMediaQueue.length > 0) {
const url = atomicMediaQueue.shift()!;
if (!usedUrls.has(url)) {
usedUrls.add(url);
pushBlock([`![](${url})`], "media");
}
}
continue;
}
@@ -243,11 +357,6 @@ function renderContentBlocks(
pushBlock([text], "text");
break;
}
const trailingMediaLines = collectMediaLines(block);
if (trailingMediaLines.length > 0) {
pushBlock(trailingMediaLines, "media");
}
}
if (inCodeBlock) {
@@ -284,7 +393,9 @@ export function formatArticleMarkdown(article: unknown): FormatArticleResult {
const blocks = candidate.content_state?.blocks;
const entityMap = candidate.content_state?.entityMap;
if (Array.isArray(blocks) && blocks.length > 0) {
const rendered = renderContentBlocks(blocks, entityMap, mediaById, usedUrls);
const atomicMediaQueue = buildAtomicMediaQueue(candidate, usedUrls);
const mediaLinkMap = buildMediaLinkMap(entityMap);
const rendered = renderContentBlocks(blocks, entityMap, mediaById, usedUrls, atomicMediaQueue, mediaLinkMap);
if (rendered.length > 0) {
if (lines.length > 0) lines.push("");
lines.push(...rendered);