用法:
bash ./push_quail.sh /Users/tetsuya/Dev/iamcheyan.com/app/pelican/content
脚本并指定包含日志的路径,脚本会批量上传文章和里面的图片到 Quaily 中。
#!/bin/bash
# 批量发布指定目录下的所有 markdown 文章到 Quaily
# 用法:./push.sh <目录路径>
set -e # 遇到错误时退出
LIST_SLUG="tetsuya" # 你的 Quaily list_slug,这里固定为 tetsuya
# 颜色定义
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m' # No Color
# 统一编码,避免中文/特殊字符路径在子进程中编码异常
export LANG=en_US.UTF-8
export LC_ALL=en_US.UTF-8
# 统计变量
total_files=0
success_count=0
error_count=0
skip_count=0
# 检查参数
if [ $# -eq 0 ]; then
echo -e "${YELLOW}用法:${NC}"
echo -e " ${BLUE}./push.sh <目录路径>${NC} # 发布指定目录下的所有 markdown 文件"
echo ""
echo -e "${YELLOW}示例:${NC}"
echo -e " ${BLUE}./push.sh .${NC} # 发布当前目录下的所有 markdown 文件"
echo -e " ${BLUE}./push.sh /path/to/articles${NC} # 发布指定目录下的所有 markdown 文件"
echo -e " ${BLUE}./push.sh ~/Documents/blog${NC} # 发布博客目录下的所有 markdown 文件"
exit 0
fi
TARGET_DIR="$1"
# 检查目录是否存在
if [ ! -d "$TARGET_DIR" ]; then
echo -e "${RED}❌ 错误: 目录不存在: $TARGET_DIR${NC}"
exit 1
fi
# 获取绝对路径
TARGET_DIR=$(cd "$TARGET_DIR" && pwd)
echo -e "${BLUE}🚀 开始批量发布文章到 Quaily...${NC}"
echo -e "${BLUE}📋 目标列表: $LIST_SLUG${NC}"
echo -e "${BLUE}📁 搜索目录: $TARGET_DIR${NC}"
echo ""
# 检查 quail-cli 是否已安装
if ! command -v quail-cli &> /dev/null; then
echo -e "${RED}❌ 错误: quail-cli 未安装或不在 PATH 中${NC}"
echo "请先安装 quail-cli: go install github.com/quail-ink/quail-cli@latest"
exit 1
fi
# 检查是否已登录
if ! quail-cli me &> /dev/null; then
echo -e "${RED}❌ 错误: 未登录 Quaily${NC}"
echo "请先运行: quail-cli login"
exit 1
fi
# 获取访问令牌
ACCESS_TOKEN=$(grep -E '^\s*access_token:' ~/.config/quail-cli/config.yaml | awk '{print $2}' | tr -d '"')
if [ -z "$ACCESS_TOKEN" ]; then
echo -e "${RED}❌ 错误: 无法获取访问令牌${NC}"
echo "请先运行: quail-cli login"
exit 1
fi
# 查找所有 .md 文件(递归搜索)
echo -e "${BLUE}🔍 正在搜索 markdown 文件...${NC}"
md_files=()
while IFS= read -r -d '' file; do
md_files+=("$file")
done < <(find "$TARGET_DIR" -name "*.md" -type f -print0)
if [ ${#md_files[@]} -eq 0 ]; then
echo -e "${YELLOW}⚠️ 在目录 $TARGET_DIR 及其子目录中没有找到 .md 文件${NC}"
exit 0
fi
total_files=${#md_files[@]}
echo -e "${BLUE}📊 找到 $total_files 个 markdown 文件${NC}"
echo ""
# 显示找到的文件列表
echo -e "${BLUE}📋 找到的文件:${NC}"
for i in "${!md_files[@]}"; do
# 计算相对路径
relative_path="${md_files[$i]#$TARGET_DIR/}"
echo -e " ${YELLOW}$((i + 1)).${NC} $relative_path"
done
echo ""
# 上传图片到 Quail 的函数
upload_image_to_quail() {
local image_path="$1"
local temp_file=$(mktemp)
local mime_type
if command -v file >/dev/null 2>&1; then
mime_type=$(file -b --mime-type "$image_path" 2>/dev/null || echo "application/octet-stream")
else
mime_type="application/octet-stream"
fi
# 将上传文件复制到仅包含 ASCII 的临时路径,避免 curl 在本地读取非 ASCII/特殊字符路径时报错
local tmp_dir
tmp_dir=$(mktemp -d)
local orig_basename
orig_basename=$(basename "$image_path")
local ascii_basename
if command -v iconv >/dev/null 2>&1; then
ascii_basename=$(printf '%s' "$orig_basename" | iconv -f UTF-8 -t ASCII//TRANSLIT 2>/dev/null | sed 's/[^A-Za-z0-9._-]/_/g')
else
ascii_basename=$(printf '%s' "$orig_basename" | sed 's/[^A-Za-z0-9._-]/_/g')
fi
[ -z "$ascii_basename" ] && ascii_basename="upload.bin"
local upload_local_path="$tmp_dir/$ascii_basename"
cp "$image_path" "$upload_local_path"
# 使用 curl 上传图片到 Quail Attachment API
local response
if ! response=$(curl -sS -w "%{http_code}" \
-H "Authorization: Bearer $ACCESS_TOKEN" \
-F "file=@${upload_local_path}" \
-o "$temp_file" \
"https://api.quail.ink/attachments"); then
echo -e " ❌ CURL 执行失败" >&2
[ -s "$temp_file" ] && echo -e " 🔎 服务返回: $(cat "$temp_file")" >&2
rm -f "$upload_local_path"
rmdir "$tmp_dir" 2>/dev/null || true
rm -f "$temp_file"
return 1
fi
local http_code="${response: -3}"
if [ "$http_code" = "200" ] || [ "$http_code" = "201" ]; then
# 解析返回的 JSON 获取 view_url(兼容 data.view_url 与顶层 view_url)
local view_url
if command -v jq >/dev/null 2>&1; then
view_url=$(jq -r '.data.view_url // .view_url // empty' "$temp_file")
else
view_url=$(grep -o '"view_url":"[^"[:space:]]*"' "$temp_file" | head -n1 | cut -d'"' -f4)
# 再次兜底:直接匹配静态域名 URL
[ -z "$view_url" ] && view_url=$(grep -o 'https://static\.quail\.ink/[^"[:space:]]*' "$temp_file" | head -n1)
fi
if [ -n "$view_url" ]; then
echo "$view_url"
rm -f "$upload_local_path"
rmdir "$tmp_dir" 2>/dev/null || true
rm -f "$temp_file"
return 0
fi
fi
echo -e " ❌ 上传接口返回状态码: $http_code" >&2
[ -s "$temp_file" ] && echo -e " 🔎 返回内容: $(cat "$temp_file")" >&2
rm -f "$upload_local_path"
rmdir "$tmp_dir" 2>/dev/null || true
rm -f "$temp_file"
return 1
}
# 处理 markdown 文件中的图片
process_images_in_file() {
local file_path="$1"
local processed_file="${file_path}.processed"
# 复制原文件到处理文件
cp "$file_path" "$processed_file"
# 查找所有本地图片路径(保留空格与特殊字符,不做单词拆分)
local images=()
while IFS= read -r img; do
# 仅保留括号内路径部分
img=${img#*(}
img=${img%)}
# 过滤 http/https
if echo "$img" | grep -qE '^https?://'; then
continue
fi
images+=("$img")
done < <(grep -o '!\[.*\]([^)]*)' "$processed_file")
if [ ${#images[@]} -eq 0 ]; then
return 0 # 没有图片需要处理
fi
local has_failed=false
for image_path in "${images[@]}"; do
# 处理相对路径
local full_path="$image_path"
if [[ "$image_path" != /* ]]; then
local base_dir=$(dirname "$file_path")
full_path="$base_dir/$image_path"
fi
if [ ! -f "$full_path" ]; then
echo -e " ❌ 图片文件不存在: $image_path"
has_failed=true
continue
fi
echo -e " 📤 上传图片: $(basename "$image_path")"
echo -e " 📍 原始路径: $image_path"
# 上传图片
local quail_url=$(upload_image_to_quail "$full_path")
if [ $? -eq 0 ] && [ -n "$quail_url" ]; then
echo -e " ✅ 上传成功"
echo -e " 🔗 新路径: $quail_url"
echo -e " ↔️ 映射: $image_path -> $quail_url"
echo -e " ✏️ 正在替换路径..."
# 替换文件中的路径(对特殊字符进行转义)
local escaped_path
escaped_path=$(printf '%s' "$image_path" | sed -e 's/[\\/&|.\[\]*^$+?{}()]/\\&/g')
sed -i.bak "s|]($escaped_path)|]($quail_url)|g" "$processed_file"
rm -f "${processed_file}.bak"
echo -e " ✅ 路径已替换"
else
echo -e " ❌ 上传失败: $image_path"
has_failed=true
fi
done
if [ "$has_failed" = "true" ]; then
return 1
fi
# 显示处理总结
echo -e " 📊 图片处理总结:"
echo -e " 🖼️ 处理图片: ${#images[@]} 个"
echo -e " ✅ 全部成功: ${#images[@]} 个"
echo -e " 📄 文件已修改: $(basename "$processed_file")"
return 0
}
# 处理每个文件
for i in "${!md_files[@]}"; do
f="${md_files[$i]}"
current=$((i + 1))
# 计算相对路径
relative_path="${f#$TARGET_DIR/}"
echo -e "${BLUE}[$current/$total_files]${NC} 处理文件: ${YELLOW}$relative_path${NC}"
# 提取 frontmatter 里的 slug 字段
slug=$(grep -E '^slug:' "$f" | head -n1 | awk '{print $2}' | tr -d '"')
if [ -z "$slug" ]; then
echo -e " ${YELLOW}⚠️ 跳过: 文件 $relative_path 没有找到 slug 字段${NC}"
((skip_count++))
echo ""
continue
fi
echo -e " 📝 Slug: $slug"
# 始终尝试处理本地图片(不修改原文件,仅对 .processed 生效)
echo -e " 🖼️ 检测并处理本地图片..."
if process_images_in_file "$f"; then
if [ -f "${f}.processed" ]; then
echo -e " ${GREEN}✅ 图片处理完成${NC}"
target_file="${f}.processed"
else
echo -e " ℹ️ 未发现本地图片"
target_file="$f"
fi
else
echo -e " ${RED}❌ 图片处理失败${NC}"
echo -e " ${YELLOW}⚠️ 跳过此文件,避免发布包含本地图片路径的文章${NC}"
((error_count++))
echo ""
continue
fi
# 执行 upsert 操作(直接发布)
echo -e " 📤 正在上传并发布文章..."
if quail-cli post upsert "$target_file" -l "$LIST_SLUG" --publish > /dev/null 2>&1; then
echo -e " ${GREEN}✅ 上传并发布成功${NC}"
# 清理临时文件
if [ -f "${f}.processed" ]; then
rm -f "${f}.processed"
fi
((success_count++))
else
echo -e " ${RED}❌ 上传或发布失败${NC}"
echo -e " ${YELLOW}💡 尝试手动发布: quail-cli post publish -l $LIST_SLUG -p $slug${NC}"
# 清理临时文件
if [ -f "${f}.processed" ]; then
rm -f "${f}.processed"
fi
((error_count++))
fi
echo ""
done
# 显示最终统计
echo -e "${BLUE}📊 处理完成!统计信息:${NC}"
echo -e " ${GREEN}✅ 成功: $success_count${NC}"
echo -e " ${RED}❌ 失败: $error_count${NC}"
echo -e " ${YELLOW}⚠️ 跳过: $skip_count${NC}"
echo -e " 📁 总计: $total_files"
if [ $error_count -eq 0 ]; then
echo -e "\n${GREEN}🎉 所有文章都已成功处理!${NC}"
else
echo -e "\n${YELLOW}⚠️ 有 $error_count 个文件处理失败,请检查错误信息${NC}"
exit 1
fi