利用 Mastodon 的 Webhook 可以主动同步 Status 到 Memos(或其他平台),而不是利用 RSS 或 Crontab 被动式同步。
请安装工具,若有报错,请安装其他对应工具
sudo apt install jq
sudo apt install lynx
以下为 Shell Script 脚本内容,请注意替换:
MEMOS_HOST=""
MEMOS_ACCESS_TOKEN=""
MEMOS_VISIBILITY=""
MASTODON_INSTANCE=""
MASTODON_ID=""
SKIP_MASTODON_REPLY=
SKIP_MASTODON_REBLOG=
HOME_DIR=~
FILE_PATH=$HOME_DIR/.mastodon_memos_id.json
AI_DIFF=true
AI_API="https://api.deepseek.com"
AI_AUTHORIZATION=""
AI_MODEL
SINK_ENABLE
SINK_HOST="https://s.e5n.cc"
SINK_NUXT_SITE_TOKEN=""
S3_ENABLE
查找 ID: https://
INSTANCE
/api/v1/accounts/lookup?acct=USERNAME
.mastodon_memos_id.json
{
"latest_memos_id": "",
"latest_mastodon_id": "",
"bind": []
}
mastodon_sync_to_memos.sh
#!/bin/bash
sleep 5
# Version: 2024.11.23
# 已测试版本:
# Memos: v0.22.5
# Mastodon: v4.3.1
# Sink: v0.1.4
# ======================================================
# 配置开始
# Memos Host
MEMOS_HOST="https://memos.eallion.com/"
# Memos Access Token
MEMOS_ACCESS_TOKEN="eyJh****"
# 发布 Memos 的可见性 ('PUBLIC', 'PROTECTED', 'PRIVATE', 'VISIBILITY_UNSPECIFIED') 四选一
MEMOS_VISIBILITY=PUBLIC
# Mastodon Instance
MASTODON_INSTANCE="https://e5n.cc/"
# Mastodon ID, Find ID: https://INSTANCE/api/v1/accounts/lookup?acct=USERNAME
MASTODON_ID="111136231674527355"
# 跳过回复和转嘟
SKIP_MASTODON_REPLY=true
SKIP_MASTODON_REBLOG=true
# 获取当前用户的家目录路径及保存 ID 的文件,保持默认,不用更改
HOME_DIR=~
FILE_PATH=$HOME_DIR/.mastodon_memos_id.json
# AI 比较文本相似度,兼容 OpenAI 格式的模型都可以
AI_DIFF=false
AI_API="https://api.deepseek.com"
AI_TOKEN="sk-****"
AI_MODEL="deepseek-chat"
# Deploy Sink: https://github.com/ccbikai/Sink
SINK_ENABLE=false
SINK_HOST="https://s.e5n.cc"
SINK_NUXT_SITE_TOKEN="SINK-****"
# 上传 Statuses 到 s3 对象存储
# 未配置好最下面的 aliyun cli 或者 coscmd,请勿打开此设置
S3_ENABLE=false
# 配置结束
# ======================================================
# 以下内容不用更改
# 检查 ID 文件是否存在
if [ ! -f "$FILE_PATH" ]; then
# 如果文件不存在,则创建文件并写入 JSON 数据
echo '
{
"latest_memos_id": "0",
"latest_mastodon_id": "0",
"bind": []
}
' > "$FILE_PATH"
echo "Data file created: $FILE_PATH"
else
# 如果文件存在,则跳过并进行后续步骤
echo "Local data exist, skipping..."
fi
# 拼接 Memos API 和 Token
if [[ "$MEMOS_HOST" != */ ]]; then
MEMOS_HOST="$MEMOS_HOST/"
fi
MEMOS_API_HOST="${MEMOS_HOST}api/v1/memos"
MEMOS_URL="${MEMOS_API_HOST}?pageSize=1&filter=creator%3D%3D%27users%2F101%27%26%26visibilities%3D%3D%5B%27PUBLIC%27%5D"
# Mastodon 的 API
if [[ "$MASTODON_INSTANCE" != */ ]]; then
MASTODON_INSTANCE="$MASTODON_INSTANCE/"
fi
MASTODON_CONTENT_URL="${MASTODON_INSTANCE}api/v1/accounts/${MASTODON_ID}/statuses?limit=1&exclude_replies=${SKIP_MASTODON_REPLY}&exclude_reblogs=${SKIP_MASTODON_REBLOG}"
# 前置判断是否为回复嘟文,减少 AI Token 开支
LATEST_CONTENT_URL="${MASTODON_INSTANCE}api/v1/accounts/${MASTODON_ID}/statuses?limit=1"
LATEST_CONTENT_RESPONSE=$(curl -s "$LATEST_CONTENT_URL")
IS_REPLY=$(echo "$LATEST_CONTENT_RESPONSE" | jq -r '.[0].in_reply_to_id')
IS_REBLOG=$(echo "$LATEST_CONTENT_RESPONSE" | jq -r '.[0].reblog')
# 检查 IS_REPLY 是否为 null
if [ "$SKIP_MASTODON_REPLY" == true ] && [ "$IS_REPLY" != "null" ]; then
echo "Latest status is reply, exiting..."
echo "Skipped: $(TZ=UTC-8 date +"%Y-%m-%d %T")"
echo "============================="
exit 0
fi
# 前置判断是否为转载,减少 AI Token 开支
if [ "$SKIP_MASTODON_REBLOG" == true ] && [ "$IS_REBLOG" != "null" ]; then
echo "Latest status is reblog, exiting..."
echo "Skipped: $(TZ=UTC-8 date +"%Y-%m-%d %T")"
echo "============================="
exit 0
fi
# Mastodon 最新 Status 的 ID
LATEST_MASTODON_ID=$(curl --connect-timeout 60 -s $MASTODON_CONTENT_URL | jq -r '.[0].id')
# Memos 获取最新的 Memos ID
LATEST_MEMOS_ID=$(curl --connect-timeout 60 -s $MEMOS_URL | jq -r '.memos[0].uid')
# 定义 LOCAL_MEMOS_ID 变量
LOCAL_MEMOS_ID=$(cat "$FILE_PATH" | jq -r '.latest_memos_id')
LOCAL_MASTODON_ID=$(cat "$FILE_PATH" | jq -r '.latest_mastodon_id')
# Webhook 触发时,判断 Mastodon 最新 ID 是否为暂存 ID,防止重复同步
if [ "$LATEST_MASTODON_ID" == "$LOCAL_MASTODON_ID" ]; then
echo "Mastodon no updated, skipping..."
echo "Skipped: $(TZ=UTC-8 date +"%Y-%m-%d %T")"
echo "============================="
exit 0
fi
CONTENT=$(curl --connect-timeout 60 -s $MASTODON_CONTENT_URL | jq -r '.[0]')
TEXT=$(echo "$CONTENT" | jq -r '.content')
# 解码 Unicode 转义序列
TEXT=$(echo "$TEXT" | sed 's/\\u[0-9a-fA-F]\{4\}/\\x/g')
# 去除外层引号
TEXT=$(echo "$TEXT" | sed 's/^"//;s/"$//')
# 处理 <span> 标签(去除)
TEXT=$(echo "$TEXT" | sed -E 's/<span[^>]*>//g;s/<\/span>//g')
# 处理 <a> 标签,根据 class 属性进行不同的处理
# 如果 class 包含 hashtag,保留标签内的文字内容
TEXT=$(echo "$TEXT" | sed -E 's/<a[^>]*class="[^"]*hashtag[^"]*"[^>]*>([^<]*)<\/a>/\1/g')
# 如果 class 不包含 hashtag,提取 href 内容
TEXT=$(echo "$TEXT" | sed -E 's/<a[^>]*href="([^"]+)"[^>]*>([^<]*)<\/a>/\1/g')
# 去除 <p> 标签
TEXT=$(echo "$TEXT" | sed 's/<p[^>]*>//g;s/<\/p>//g')
# 处理 <br /> 标签,替换为换行符
TEXT=$(echo "$TEXT" | sed 's/<br \/>/\n/g')
# 处理 blockquote,替换为 Markdown 的 `>`
TEXT=$(echo "$TEXT" | sed 's/<blockquote>/>/g;s/<\/blockquote>//g')
# 替换 >
TEXT=$(echo "$TEXT" | sed 's/\>\;/>/g')
MEDIA=$(echo $CONTENT | jq -r '.media_attachments')
# 判断 Media 的内容
if [ "$MEDIA" != "null" ]; then
MEDIAS=$(echo $CONTENT | jq -r '.media_attachments[] | select(.type=="image") | .url')
# 拼接图片
images=""
for url in $MEDIAS; do
images="$images\n"
done
TEXT=$(echo "$TEXT\n$images" | sed 's/\\n*$//')
else
# 普通内容
TEXT=$(echo "$TEXT" | sed 's/\\n*$//')
fi
# 判断内容是否为空
if [ -z "$TEXT" ] || [ "$TEXT" == "\\n" ]; then
echo "Content is empty, skipping..."
echo "Skipped: $(TZ=UTC-8 date +"%Y-%m-%d %T")"
echo "============================="
exit 0
fi
# 双引号转义
TEXT=$(echo "$TEXT" | sed 's/"/\\"/g')
# Webhook 触发时,判断 Memos 最新 ID 是否为暂存 ID
# 当 Memos 单方面有更新后,验证 Mastodon 和 Memos 的 ID 绑定关系(Todo)
# if [ "$LATEST_MEMOS_ID" == "$LOCAL_MEMOS_ID" ]; then
# echo "Memos no updated, skipping..."
# echo "Skipped: $(TZ=UTC-8 date +"%Y-%m-%d %T")"
# echo "============================="
# exit 0
# fi
# 利用 Deepseek 对比 Mastodon 和 Memos 的相似度
CONTENT_MEMOS=$(curl --connect-timeout 60 -s $MEMOS_URL | jq -r '.memos[0].content')
CONTENT_MASTODON=$TEXT
if [[ "$AI_DIFF" == true ]]; then
REQUEST_BODY=$(cat <<EOF
{
"messages": [
{
"content": "你是一个比较文本相似度的助手",
"role": "system"
},
{
"content": "比较文本1:“ --- $CONTENT_MEMOS --- ”和文本2:“ --- $CONTENT_MASTODON --- ”的相似度,超过65%的相似度就判定为相似,如果相似就回答数字1,如果不相似就回答数字0,除了数字1或者数字0不能回答其他任何内容。",
"role": "user"
}
],
"model": "$AI_MODEL",
"frequency_penalty": 0,
"max_tokens": 2048,
"presence_penalty": 0,
"stop": null,
"stream": false,
"temperature": 1,
"top_p": 1,
"logprobs": false,
"top_logprobs": null
}
EOF
)
AI_RESPONSE=$(curl -s -L -X POST "$AI_API/chat/completions" \
-H 'Content-Type: application/json' \
-H 'Accept: application/json' \
-H "Authorization: Bearer $AI_TOKEN" \
--data-raw "$REQUEST_BODY")
AI_DIFF_RESULT=$(echo "$AI_RESPONSE" | jq -r '.choices[0].message.content')
if [ "$AI_DIFF_RESULT" == 1 ]; then
echo "[AI] Content is duplicate, skipping..."
echo "Skipped: $(TZ=UTC-8 date +"%Y-%m-%d %T")"
echo "============================="
exit 0
fi
else
# 对比 Matodon 和 Memos 的 Content 内容的 MD5 值(不一定精确)
# 获取最新 Memos 的 MD5
LATEST_MEMOS_MD5=$(echo $CONTENT_MEMOS | tr -d '"' | md5sum | cut -d' ' -f1)
# 获取最新 Mastodon 的 MD5
LATEST_TEXT_MD5=$(echo $TEXT | tr -d '"' | md5sum | cut -d' ' -f1)
# 通过 MD5 判断内容是否重复
if [ "$LATEST_TEXT_MD5" == "$LATEST_MEMOS_MD5" ]; then
echo "[MD5] Content is duplicate, skipping..."
echo "Skipped: $(TZ=UTC-8 date +"%Y-%m-%d %T")"
echo "============================="
exit 0
fi
fi
# 替换 NeoDB 的评分 Emoji
TEXT=$(echo "$TEXT" | sed "s/:star_empty:/🌑/g; s/:star_half:/🌗/g; s/:star_solid:/🌕/g")
# 去掉最末尾的空行
TEXT=$(echo "$TEXT" | sed 's/\\n$//')
# 发布 Memos 并获取返回的 JSON 数据
MEMOS_RESPONSE=$(curl --request POST \
--url $MEMOS_API_HOST \
--header "Authorization: Bearer $MEMOS_ACCESS_TOKEN" \
--data "{
\"content\": \"$TEXT\",
\"visibility\": \"$MEMOS_VISIBILITY\"
}")
# 从返回的 JSON 数据中提取 Memos 的 id 值
NEW_MEMOS_ID=$(echo "$MEMOS_RESPONSE" | jq -r '.uid')
# 更新 JSON 文件中的 latest_memos_id 的值
jq ".latest_memos_id = \"$NEW_MEMOS_ID\"" "$FILE_PATH" > "${FILE_PATH}.tmp" && mv "${FILE_PATH}.tmp" "$FILE_PATH"
# 更新 JSON 文件中的 latest_mastodon_id 的值
jq ".latest_mastodon_id = \"$LATEST_MASTODON_ID\"" "$FILE_PATH" > "${FILE_PATH}.tmp" && mv "${FILE_PATH}.tmp" "$FILE_PATH"
# 更新 Mastodon 和 Memos 的 ID 的绑定关系,并确保 "bind" 中的数组保留唯一键,键也只有唯一值
jq ".bind += [{\"$LATEST_MASTODON_ID\": \"$NEW_MEMOS_ID\"}] | .bind = (.bind | unique)" "$FILE_PATH" > "${FILE_PATH}.tmp" && mv "${FILE_PATH}.tmp" "$FILE_PATH"
# POST 到 Sink
if [[ "$SINK_ENABLE" == true ]]; then
SINK_URL="${MASTODON_INSTANCE}@eallion/${LATEST_MASTODON_ID}"
SINK_SLUG="${NEW_MEMOS_ID}"
curl -s -X POST \
-H "authorization: Bearer ${SINK_NUXT_SITE_TOKEN}" \
-H "content-type: application/json" \
-d "{\"url\": \"${SINK_URL}\", \"slug\": \"${SINK_SLUG}\"}" \
"${SINK_HOST}/api/link/create"
fi
# 上传 Statuses 到 s3
if [[ "$S3_ENABLE" == true ]]; then
URL_E5N="${MASTODON_INSTANCE}api/v1/accounts/${MASTODON_ID}/statuses?limit=20&exclude_replies=${SKIP_MASTODON_REPLY}&exclude_reblogs=${SKIP_MASTODON_REBLOG}"
URL_EMOJI="${MASTODON_INSTANCE}api/v1/custom_emojis"
OUTPUT_E5N="statuses"
OUTPUT_EMOJI="custom_emojis"
curl -s "$URL_E5N" > "$OUTPUT_E5N" || { echo "Error fetching e5n data"; exit 1; }
curl -s "$URL_EMOJI" > "$OUTPUT_EMOJI" || { echo "Error fetching e5n data"; exit 1; }
# coscmd upload statuses api/v1/accounts/111136231674527355/ -f -H "{'Content-Type':'application/json'}"
# coscmd upload custom_emojis api/v1/ -f -H "{'Content-Type':'application/json'}"
# tccli teo CreatePurgeTask --cli-unfold-argument --ZoneId zone-2ssxnpo34mu5 --Type purge_host --Method delete --Targets 'mastodon.api.eallion.com'
# aliyun oss cp statuses oss://eallion-com/api/v1/accounts/111136231674527355/ --meta Content-Type:application/json -f
# aliyun oss cp custom_emojis oss://eallion-com/api/v1/ --meta Content-Type:application/json -f
rm statuses
rm custom_emojis
fi
echo "Sync Mastodon to Memos Successful!"
echo "Done: $(TZ=UTC-8 date +"%Y-%m-%d %T")"
echo "============================="