分类 分享 下的文章

简介

一个All-in-one的项目,集成了uptime-kuma+ nezha + umami 的功能的超强应用.

项目地址

https://github.com/msgbyte/tianji

预览

请输入图片描述

部署

使用 docker compose 的方式来部署

mkdir /home/tianji
cd /home/tianji
vi docker-compose.yaml

docker-compose.yaml的内容如下

services:
  tianji:
    image: moonrailgun/tianji
    ports:
      - "12345:12345"
    environment:
      DATABASE_URL: postgresql://tianji:tianji@postgres:5432/tianji
      JWT_SECRET: replace-me-with-a-random-string
      ALLOW_REGISTER: "false"
      ALLOW_OPENAPI: "true"
    depends_on:
      - postgres
    restart: always
  postgres:
    image: postgres:15.4-alpine
    environment:
      POSTGRES_DB: tianji
      POSTGRES_USER: tianji
      POSTGRES_PASSWORD: tianji
    volumes:
      - ./tianji-db-data:/var/lib/postgresql/data
    restart: always
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"]
      interval: 5s
      timeout: 5s
      retries: 5

运行容器

docker compose up -d

默认密码

Url: http://<your-ip>:12345
Default username: admin
Default password: admin

监控

https://0tz.top/status/imsun
可以使用一个小徽章显示状态
博客

遥测

在页面插入一张图片就可以侦测到访问量,但是只能显示 pv 浏览量

统计

多用户

没错,Tianji是支持多用户使用的,若是有想体验的朋友可以留言,我可以发送邀请.

通知

可以支持多种通知方式

访客统计

从木木老师那里得到灵感,使用API获取网站的统计数据并使用js 调用,替代Umami.

在后台获取token websiteID workspaceID

然后使用Cloudflare Workers来保护token.
新建一个workers,填入以下代码(需要修改的部分已经标注出来)

addEventListener('fetch', event => {
  event.respondWith(handleRequest(event.request))
})

const tianji = 'https://tianji.top'; #自行修改
const token = 'sk_cf38cfb0bcb22d****'; #自行修改
const workspaceID = 'clnzoxcy10001vy2ohi4obbi0'; #自行修改
const websiteID = 'cm3sj40cs00018n2ezrgbdl5w'; #自行修改

async function handleRequest(request) {
  const url = `${tianji}/open/workspace/${workspaceID}/website/${websiteID}/stats`;

  // 获取当前时间的 Unix 时间戳(以毫秒为单位)
  const now = Date.now();

  // 设置查询参数
  const params = new URLSearchParams({
    startAt: '1704067200000', // 2024年1月1日的 Unix 时间戳(毫秒)
    endAt: now.toString()  
  });

  const fullUrl = `${url}?${params.toString()}`;

  const headers = new Headers({
    'Authorization': `Bearer ${token}`,
    'Content-Type': 'application/json'
  });

  const response = await fetch(fullUrl, {
    method: 'GET',
    headers: headers
  });

  const corsHeaders = {
    'Access-Control-Allow-Origin': '*',
    'Access-Control-Allow-Methods': 'GET, OPTIONS',
    'Access-Control-Allow-Headers': 'Content-Type, Authorization'
  };

  if (request.method === 'OPTIONS') {
    return new Response(null, {
      headers: corsHeaders
    });
  }

  if (response.ok) {
    const data = await response.json();
    return new Response(JSON.stringify(data), {
      headers: {
        'Content-Type': 'application/json',
        ...corsHeaders
      }
    });
  } else {
    return new Response(`Failed to fetch data: ${response.status} ${response.statusText}`, {
      status: response.status,
      headers: corsHeaders
    });
  }
}

startAt 的值 是某时间的unix时间戳 可以自行设置查询范围

调用

使用html+js调用

👣<p>本站到访<span id="uniques">0</span>位朋友.</p><p>共浏览页面<span id="pageviews">0</span>次</p>
<script>
// 定义 API URL
const apiUrl = 'https://tj.imsun.org';

fetch(apiUrl)
    .then(response => {
        if (!response.ok) {
            throw new Error('error');
        }
        return response.json();
    })
    .then(data => {
        const pageviewsElement = document.getElementById('pageviews');
        const uniquesElement = document.getElementById('uniques');
        pageviewsElement.textContent = data.pageviews.value;
        uniquesElement.textContent = data.uniques.value;
    })
    .catch(error => {
        console.error('获取数据时出现问题:', error);
    });
</script>

其他

还有其他很多功能,等待摸索


前言

使用 neodb.social 的API算是多一种选择.
豆瓣数据的获取还是不太方便

获取neodb的token

使用mastodon账号登录 https://neodb.social/
在右上角头像点击- 设置 - 找到更多设置
2024-12-26T00:09:57.png
点击 查看已授权的应用程序

2024-12-26T00:11:01.png
生成一个token即可

获取neodb API

在此使用项目
https://github.com/Lyunvy/neodb-shelf-api

可以部署在vercel上,过程就不赘述了

调用

在本主题的基础上修改

JS

class NeoDB {
    constructor(config) {
        this.container = config.container;
        this.types = config.types ?? ["book", "movie", "tv", "music", "game", "podcast"];
        this.baseAPI = config.baseAPI;
        this.type = "movie";
        this.status = "complete";
        this.finished = false;
        this.paged = 1;
        this.subjects = [];
        this._create();
    }

    on(event, element, callback) {
        const nodeList = document.querySelectorAll(element);
        nodeList.forEach((item) => {
            item.addEventListener(event, callback);
        });
    }

    _handleTypeClick() {
        this.on("click", ".neodb-navItem", (t) => {
            const self = t.currentTarget;
            if (self.classList.contains("current")) return;
            this.type = self.dataset.type;
            document.querySelector(".neodb-list").innerHTML = "";
            document.querySelector(".lds-ripple").classList.remove("u-hide");
            document.querySelector(".neodb-navItem.current").classList.remove("current");
            self.classList.add("current");
            this.paged = 1;
            this.finished = false;
            this.subjects = [];
            this._fetchData();
        });
    }

    _renderTypes() {
        document.querySelector(".neodb-nav").innerHTML = this.types
            .map((item) => {
                return `<span class="neodb-navItem${
                    this.type == item ? " current" : ""
                }" data-type="${item}">${item}</span>`;
            })
            .join("");
        this._handleTypeClick();
    }

    _fetchData() {
        const params = new URLSearchParams({
            type: "complete",
            category: this.type,
            page: this.paged.toString(),
        });
    
        return fetch(this.baseAPI + "?" + params.toString())
            .then((response) => response.json())
            .then((data) => {
                if (data.length) {
                    // 过滤重复项
                    data = data.filter(item => !this.subjects.some(existing => existing.item.id === item.item.id));
                    
                    if (data.length) {
                        this.subjects = [...this.subjects, ...data];
                        this._renderListTemplate();
                    }
    
                    document.querySelector(".lds-ripple").classList.add("u-hide");
                } else {
                    this.finished = true; // 没有更多数据
                    document.querySelector(".lds-ripple").classList.add("u-hide");
                }
            });
    }

    _renderListTemplate() {
        document.querySelector(".neodb-list").innerHTML = this.subjects
            .map((item) => {
                const coverImage = item.item.cover_image_url;
                const title = item.item.title;
                const rating = item.item.rating;
                const link = item.item.id;

                return `<div class="neodb-item">
                    <img src="${coverImage}" referrerpolicy="no-referrer" class="neodb-image">
                    <div class="neodb-score">
                        ${rating ? `<svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor"><path d="M12 20.1l5.82 3.682c1.066.675 2.37-.322 2.09-1.584l-1.543-6.926 5.146-4.667c.94-.85.435-2.465-.799-2.567l-6.773-.602L13.29.89a1.38 1.38 0 0 0-2.581 0l-2.65 6.53-6.774.602C.052 8.126-.453 9.74.486 10.59l5.147 4.666-1.542 6.926c-.28 1.262 1.023 2.26 2.09 1.585L12 20.099z"></path></svg>${rating}` : ""}
                    </div>
                    <div class="neodb-title">
                        <a href="${link}" target="_blank">${title}</a>
                    </div>
                    
                </div>`;
            })
            .join("");
    }

    _handleScroll() {
        let isLoading = false; // 标志位,表示是否正在加载数据
        let lastScrollTop = 0; // 上一次的滚动位置
    
        window.addEventListener("scroll", () => {
            const scrollY = window.scrollY || window.pageYOffset;
            const moreElement = document.querySelector(".block-more");
    
            // 检查滚动到底部的条件
            if (
                moreElement.offsetTop + moreElement.clientHeight <= scrollY + window.innerHeight &&
                document.querySelector(".lds-ripple").classList.contains("u-hide") &&
                !this.finished &&
                !isLoading // 确保没有正在加载数据
            ) {
                isLoading = true; // 设置标志位为 true,表示正在加载数据
                document.querySelector(".lds-ripple").classList.remove("u-hide");
                this.paged++;
                this._fetchData().finally(() => {
                    isLoading = false; // 数据加载完成后,重置标志位
                });
            }
    
            // 更新上一次的滚动位置
            lastScrollTop = scrollY;
        });
    }

    _create() {
        if (document.querySelector(".neodb-container")) {
            const container = document.querySelector(this.container);
            if (!container) return;
            container.innerHTML = `
                <nav class="neodb-nav"></nav>
                <div class="neodb-list"></div>
                <div class="block-more block-more__centered">
                    <div class="lds-ripple"></div>
                </div>
            `;
            this._renderTypes();
            this._fetchData();
            this._handleScroll();
        }
    }
}

CSS

.neodb-container{--db-item-width:150px;--db-item-height:180px;--db-music-width:150px;--db-music-height:150px;--db-primary-color:var(--farallon-hover-color);--db-background-white:var(--farallon-background-white);--db-background-gray:var(--farallon-background-gray);--db-border-color:var(--farallon-border-color);--db-text-light:var(--farallon-text-light);}.neodb-nav{padding:30px 0 20px;display:flex;align-items:center;flex-wrap:wrap;}.neodb-navItem{font-size:20px;cursor:pointer;border-bottom:1px solid rgba(0,0,0,0);transition:0.5s border-color;display:flex;align-items:center;text-transform:capitalize;}.neodb-navItem.current,.neodb-navItem:hover{border-color:inherit;}.neodb-navItem{margin-right:20px;}.neodb-score svg{fill:#f5c518;margin-right:5px;}.neodb-list{display:flex;align-items:flex-start;flex-wrap:wrap;}.neodb-image{width:var(--db-item-width);height:var(--db-item-height);object-fit:cover;border-radius:4px;}.neodb-image:hover{box-shadow:0 0 10px var(--db-border-color);}.neodb-title{margin-top:2px;font-size:14px;line-height:1.4;}.neodb-title a:hover{color:var(--db-primary-color);text-decoration:underline;}.neodb-genreItem{background:var(--db-background-gray);font-size:12px;padding:5px 12px;border-radius:4px;margin-right:6px;margin-bottom:10px;line-height:1.4;cursor:pointer;}.neodb-genreItem.is-active,.neodb-genreItem:hover{background-color:var(--db-primary-color);color:var(--db-background-white);}.neodb-genres{padding-bottom:15px;display:flex;flex-wrap:wrap;}.neodb-genres.u-hide + .neodb-list{padding-top:10px;}.neodb-score{display:flex;align-items:center;font-size:14px;color:var(--db-text-light);}.neodb-item{width:var(--db-item-width);margin-right:20px;margin-bottom:20px;position:relative;}.neodb-item__music img{width:var(--db-music-width);height:var(--db-music-height);object-fit:cover;}.neodb-date{position:relative;font-size:20px;color:var(--farallon-text-light);font-weight:900;line-height:1;}.neodb-date::before{content:"";position:absolute;top:0.5em;bottom:-2px;left:-10px;width:3.4em;z-index:-1;background:var(--farallon-hover-color);opacity:0.3;transform:skew(-35deg);transition:opacity 0.2s ease;border-radius:3px 8px 10px 6px;}.neodb-date{margin-top:30px;margin-bottom:10px;}.neodb-dateList{padding-left:15px;padding-top:5px;padding-right:15px;}.neodb-card__list{display:flex;align-items:center;padding:15px 0;border-bottom:1px dotted var(--farallon-border-color);font-size:14px;color:rgba(0,0,0,0.55);}.neodb-card__list:last-child{border-bottom:0;}.neodb-card__list .title{font-size:18px;margin-bottom:5px;}.neodb-card__list .rating{margin:0 0 0px;font-size:14px;line-height:1;display:flex;align-items:center;}.neodb-card__list .rating .allstardark{position:relative;color:#f99b01;height:16px;width:80px;background-repeat:repeat;background-image:url("../images/star.svg");background-size:auto 100%;margin-right:5px;}.neodb-card__list .rating .allstarlight{position:absolute;left:0;color:#f99b01;height:16px;overflow:hidden;background-repeat:repeat;background-image:url("../images/star-fill.svg");background-size:auto 100%;}.neodb-card__list img{width:80px;border-radius:4px;height:80px;object-fit:cover;flex:0 0 auto;margin-right:15px;}.neodb-titleDate{display:flex;flex-direction:column;line-height:1.1;margin-bottom:10px;flex:0 0 auto;margin-right:15px;align-items:center;}.neodb-titleDate__day{font-weight:900;font-size:44px;}.neodb-titleDate__month{font-size:14px;color:var(--farallon-text-light);font-weight:900;}.neodb-list__card{display:block;}.neodb-dateList__card{display:flex;flex-wrap:wrap;align-items:flex-start;}.neodb-listBydate{display:flex;align-items:flex-start;margin-top:15px;}@media (max-width:600px){.neodb-listBydate{flex-direction:column;}}

HTML

    <div class="neodb-container"></div>
<script>
const neodb = new NeoDB({
    container: ".neodb-container",
    baseAPI: "https://neodb.imsun.org/api",
    types: ["book", "movie", "tv", "music", "game"],
});    
</script>

其中https://neodb.imsun.org/为 部署在 vercel 的绑定域名,可自行更改


Mastodon 是什么?

是自己也是全世界.

它可以私有化部署,所以说他是自己.

它可以连通世界,与世界各地的实例进行交互.所以也可以说是全世界.

它之所以被称之为联邦宇宙,自然与联邦政府类似,各自为政,但是相互连接.

相关文章

[article id="1663"]
[article id="1537"]
[article id="1469"]

如何部署

直接使用docker compose部署是不可行的,需要按照步骤进行

创建目录

mkdir -p /home/mastodon/mastodon

进入目录

cd /home/mastodon/mastodon

拉取镜像

docker pull ghcr.io/mastodon/mastodon

修改docker compose配置文件

wget https://raw.githubusercontent.com/mastodon/mastodon/main/docker-compose.yml

修改docker compose文件中的版本号

初始化PostgreSQL

  • 重要!!!!!
docker run --name postgres14 -v /home/mastodon/mastodon/postgres14:/var/lib/postgresql/data -e   POSTGRES_PASSWORD=设置数据库管理员密码 --rm -d postgres:14-alpine

进入数据库

docker exec -it postgres14 psql -U postgres

创建用户名mastodon的密码

CREATE USER mastodon WITH PASSWORD '数据库密码(最好和数据库管理员密码不一样)' CREATEDB;

停止docker

docker stop postgres14

配置Mastodon

/home/mastodon/mastodon根文件夹中创建空白.env.production文件

cd /home/mastodon/mastodon
touch .env.production

运行引导

docker-compose run --rm web bundle exec rake mastodon:setup

按照提示进行操作

Below is your configuration, save it to an .env.production file outside Docker:之后会出现配置文件的数据,复制下来

写入.env.production

启动Mastodon

docker-compose down
docker-compose up -d

文件夹赋权

chown 991:991 -R ./public
chown -R 70:70 ./postgres14
docker-compose down
docker-compose up -d

创建管理员

docker exec mastodon-web-1 tootctl accounts create USERNAME --email EMAIL --confirmed --role Owner

至此完成


之前我介绍过easypanel,但是它是大部分功能免费使用,部分功能是收费使用的

[article id="1522"]

这次介绍的 Dokploy 是一款稳定、易于使用的部署解决方案,旨在简化应用程序管理流程。 可将 Dokploy 视为 Heroku、Vercel 和 Netliify 等平台的免费可托管替代品, 使用稳定的 Docker 和灵活的 Traefik 构建.

它有S3备份,自动续签证书,还可以设置集群,多用户使用.等等.

官网中文网站

https://dokploy.com/zh-Hans

官方中文文档

https://docs.dokploy.com/cn/docs/core/get-started/installation

使用

Dokploy的安装部署也十分方便,只需要在纯净的系统中执行命令

curl -sSL https://dokploy.com/install.sh | sh

即可运行.

访问 ip:3000 先创建一个管理员账号

可以在后台设置中绑定一个域名来访问面板
1728618429226.png

这里我们来做一个基本的演示,部署一个memos服务
首先创建一个项目 点击Project-Create Project-Add a project

1728618533812.png

创建好之后进入该项目
1728618676972.png

部署数据库

点击Create Service
1728618718046.png

先创建一个database
1728618830205.png
1728618883740.png

点击Deploy部署.

成功后可以点击复制此处的数据库链接
1728618968708.png

创建应用

再新建一个Application
1728619030565.png
点击进入详情
2024-10-11T03:58:17.png
填入镜像ghcr.io/usememos/memos

环境变量

Environment Settings
填入

MEMOS_DRIVER=postgres
MEMOS_DSN=获取到的数据库链接

映射目录

Advanced-Volumes中设置 映射目录
Add Volumes-Volumes / Mounts
Volume Name 随便填写
Mount Path (In the container)填入/var/opt/memos

绑定域名

Domains
2024-10-11T05:40:25.png
如此添加即可,最好提前解析

完成所有设置
点击Deploy部署完成
等待片刻,打开绑定的域名查看结果.


根据上文

[article id="1668"]

使用docker-compose部署
执行

mkdir qq2memos
cd qq2memos
vim docker-compose.yaml

输入以下内容

services:
  napcat:
    container_name: napcat
    mac_address: 02:42:ac:11:00:91 #自己修改
    environment:
      - ACCOUNT=3319693101 #QQ机器人号码
      - WSR_ENABLE=true
      - WS_URLS=["ws://memos:8080/onebot/v11/ws"]
      - NAPCAT_UID=0
      - NAPCAT_GID=0
    ports:
      - 6099:6099
      - 3000:3000
    restart: always
    image: mlikiowa/napcat-docker:latest
    volumes:
      - "./QQ:/app/.config/QQ"
      - "./config:/app/napcat/config"
    networks: 
      - memos
  memos:
    container_name: memos
    environment:
      - MEMOS_API=https://memos.imsun.org/api/v1/memo ##自己修改
    image: jkjoy/qq2memos:latest  
    volumes:  
      - "./data:/app/data"  
    restart: always
    networks: 
      - memos
networks:
  memos:

执行

docker-compose up -d

登录napcat的webui
ip:6099/webui/login.html

填写反向WS地址为

ws://memos:8080/onebot/v11/ws

即可

在群晖部署可使用
2024-10-04T11:22:28.png
提前在/docker/qqbot路径下 创建 data QQ config三个文件夹以免权限不足构建失败


简介

介绍如何使用Docker快速部署一个QQ机器人并对接Nonebot实现Memos机器人的功能:

  • 绑定memos账号
  • 转发消息发送到memos

步骤

部署QQ机器人

这里使用的项目是基于QQNT的无头机器人方案,使用webui登录,相对于之前我部署的Go-cqhttp的方案的好处是不会被风控掉线.
稳定性很nice

使用的项目地址
使用的项目文档: https://llonebot.github.io/zh-CN/guide/getting-started

Windows系统

在windows下非常简单,下载QQNT版本的QQ,登录你的QQ机器人账号

https://github.com/super1207/install_llob/releases

下载 exe,双击运行即可,之后打开 QQ 的设置,看到了 LLOneBot 就代表安装成功了。

Linux系统

在linux下 我选择使用 的项目 NapCatQQ
地址 : https://github.com/NapNeko/NapCatQQ

使用Docker部署
docker-compose.yaml内容如下

services:
  napcat:
    environment:
      - ACCOUNT=153985848 #QQ机器人号码
      - WS_ENABLE=true
      - NAPCAT_UID=0
      - NAPCAT_GID=0
    ports:
      - 3001:3001 #上传端口
      - 6099:6099 #webui端口
      - 3000:3000 #http端口 
    restart: always
    image: mlikiowa/napcat-docker:latest
    volumes:
      - "./napcat/app/.config/QQ:/app/.config/QQ"
      - "./napcat/app/napcat/config:/app/napcat/config"
    network_mode: host #使用host的原因是为了方便对接宿主机的nonebot框架

启动

docker-compose up -d

访问 http://ip:6099/webui/login.html

注意 : 登录所使用的 token 在docker-compose.yaml 所在目录下的
/napcat/app/napcat/config中的webui.json
QQ20240912-083331.png

扫码登录

在设置页面中添加反向 WS 地址,地址为 ws://127.0.0.1:8080/onebot/v11/ws, 这里的 8080 是 NoneBot 输出的端口号,/onebot/v11/ws 是 NoneBot onebot 适配器默认的路径
2024-09-12T00:36:18.png

部署nonebot

要求环境​Python 版本 >= 3.9

按照文档操作 https://llonebot.github.io/zh-CN/guide/nonebot2

Memos转发机器人的实现

在nonebot 项目中

新建 bot.py 内容为

import nonebot
from nonebot.adapters.onebot.v11 import Adapter as ONEBOT_V11Adapter

# 初始化 NoneBot
nonebot.init()

# 注册适配器
driver = nonebot.get_driver()
driver.register_adapter(ONEBOT_V11Adapter)

# 在这里加载插件
nonebot.load_builtin_plugins("echo")  # 加载内置插件
nonebot.load_from_toml("pyproject.toml")  # 从 toml 文件加载插件

# 如果有额外的插件目录,可以这样加载
# nonebot.load_plugins("src/plugins")

if __name__ == "__main__":
    nonebot.run()

新建 memos/plugins 文件夹 , 在其下创建 qq_to_memos.py 内容为

from nonebot import on_command, on_message, get_driver
from nonebot.rule import to_me
from nonebot.adapters.onebot.v11 import Bot, Event, Message
from nonebot.params import CommandArg
import json
import os
import httpx
from typing import Dict, Any
import logging

# 配置日志
logging.basicConfig(
    level=logging.INFO,
    format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
    filename='memos_bot.log',
    filemode='a'
)
logger = logging.getLogger(__name__)

# 文件路径
JSON_FILE = "users_data.json"

# 读取 JSON 数据
def read_json() -> Dict[str, Any]:
    if os.path.exists(JSON_FILE):
        with open(JSON_FILE, 'r', encoding='utf-8') as f:
            return json.load(f)
    return {}

# 写入 JSON 数据
def write_json(data: Dict[str, Any]):
    with open(JSON_FILE, 'w', encoding='utf-8') as f:
        json.dump(data, f, ensure_ascii=False, indent=4)

# 初始化函数
async def init():
    if not os.path.exists(JSON_FILE):
        write_json({})
        logger.info(f"Created new JSON file: {JSON_FILE}")

# 注册命令
start = on_command("start", rule=to_me(), priority=5)

@start.handle()
async def handle_start(bot: Bot, event: Event, args: Message = CommandArg()):
    user_id = event.get_user_id()
    token = args.extract_plain_text().strip()
    if not token:
        await start.finish(" 请提供 Token,格式:/start <token>")
        logger.warning(f"User {user_id} failed to start due to missing token")
        return

    users_data = read_json()
    users_data[user_id] = {"token": token}
    write_json(users_data)

    logger.info(f"User {user_id} started successfully")
    await start.finish(" 绑定成功!现在您可以直接发送消息,我会将其保存到 Memos。")

# 处理所有消息
memo = on_message(priority=5)

@memo.handle()
async def handle_memo(bot: Bot, event: Event):
    user_id = event.get_user_id()
    message = event.get_message()

    users_data = read_json()
    user_info = users_data.get(user_id)

    if not user_info:
        await memo.finish(" 您还未绑定,请先使用 /start <token> 命令绑定。")
        logger.warning(f"Unstarted user {user_id} attempted to send a memo")
        return

    token = user_info["token"]

    text_content = message.extract_plain_text()

    # 如果消息为空,不处理
    if not text_content.strip():
        return

    # 发送到 Memos
    async with httpx.AsyncClient() as client:
        try:
            payload = {
                "content": text_content.strip(),
                "visibility": "PUBLIC"
            }
            response = await client.post(
                "https://memos.ee/api/v1/memos",
                json=payload,
                headers={"Authorization": f"Bearer {token}"}
            )
            response.raise_for_status()
            logger.info(f"Memo sent successfully for user {user_id}")

        except httpx.HTTPStatusError as e:
            logger.error(f"HTTP error occurred for user {user_id}: {e}")
            logger.error(f"Response content: {e.response.text}")
            await memo.finish(f" 发送失败,错误代码:{e.response.status_code},请检查您的 Token 和网络连接。")
            return
        except Exception as e:
            logger.error(f"Unexpected error occurred for user {user_id}: {e}")
            await memo.finish(" 发送过程中发生意外错误,请稍后重试。")
            return

    await memo.finish(" 已成功发送到 Memos!")

# 获取驱动器并注册启动事件
driver = get_driver()
driver.on_startup(init)

logger.info("Memos bot plugin initialized")

API 端点按自己需求更改即可.

在 pyproject.toml 中加入

plugins = []
plugin_dirs = ["memos/plugins"]

运行机器人

nb run

使用机器人

在聊天界面 使用命令

/start token

token 为 Memos 后台获取的对应的token
绑定成功后如下图效果
2024-09-12T00:44:49.png

然后直接发送消息

此时发送消息即可转发到 memos. 且默认为公开的 memos.

如需默认为其他状态 需修改 qq_to_memos.py 中 "visibility" 的 值 .

结尾

如果你想尝试一下此功能可以添加 QQ 机器人

153985848
需添加为好友且 在 https://memos.ee 注册获取 token 便可以使用.


前言

[article id="1461"]

之前的docker 我测试仅仅支持我构建的2.4.2版本

这次支持最新的2.7.0版本,所以写了这次的更新

步骤

克隆仓库

git clone https://git.ima.cm/jkjoy/pleroma-docker-compose.git
cd pleroma-docker-compose

编辑配置

注意:
你需要编辑./environments/pleroma/pleroma.env 其中的 ops.pleroma.social 为你自己的域名

启动容器

执行

docker-compuse up -d

在初始化之后反向代理4000端口即可.

创建管理员

docker exec -it pleroma sh ~/bin/./pleroma_ctl user new admin admin@ow3.cn --admin

[article id=1388]

介绍了如何优雅的部署Gotosocial.

本文是Gotosocial的使用进阶教程,如有不足,请指正

部署

根据官方示例文档,使用docker compose部署
编写docker-compose.yaml,如下

services:
  gotosocial:
    image: superseriousbusiness/gotosocial:latest
    container_name: gotosocial
    networks:
      - gotosocial
    environment:
      GTS_HOST: ima.cm
      GTS_DB_TYPE: sqlite
      GTS_DB_ADDRESS: /gotosocial/storage/sqlite.db
      GTS_STORAGE_BACKEND: s3
      GTS_STORAGE_S3_BUCKET: 
      GTS_STORAGE_S3_ENDPOINT: 
      GTS_STORAGE_S3_ACCESS_KEY: 
      GTS_STORAGE_S3_SECRET_KEY: 
      GTS_STORAGE_S3_PROXY: false
      GTS_ACCOUNTS_ALLOW_CUSTOM_CSS: true
      TZ: Asia/Chongqing
    ports:
      - "127.0.0.1:8080:8080"  
    volumes:
      - ./data:/gotosocial/storage
      - ./web:/gotosocial/web #映射出来
    restart: "always"

networks:
  gotosocial:
    ipam:
      driver: default

主要是为了把容器内的/gotosocial/web目录映射到本地目录./web,然后我们可以根据自己的需求来修改前端的模板

由于源码内的前端资源需要编译,所以我偷懒打包了一份编译好的提供给大家使用,

下载地址

该主题我粗略翻译了一下,演示如下

https://ima.cm

Environment

根据官方文档,参考设置文件config.yaml来自定义

https://github.com/superseriousbusiness/gotosocial/blob/main/example/config.yaml

其中,如果想更改数据库类型可以参照db-type: "postgres"来定义,在Environment 中必须全部使用大写字母和下划线,如GTS_DB_TYPE.:后面的设定值无需双引号""包裹.

WEB设置

默认值

# Default: "./web/template/"
web-template-base-dir: "./web/template/"
# Default: "./web/assets/"
web-asset-base-dir: "./web/assets/"

也可以通过更改Environment设置来自定义WEB的映射目录.

实例语言设置,貌似暂时不支持中文

# Example: ["nl", "en-gb", "fr"]
# Default: []
instance-languages: []

自定义CSS 的设置

# Options: [true, false]
# Default: false 默认关闭
accounts-allow-custom-css: false

如需开启则需要

GTS_ACCOUNTS_ALLOW_CUSTOM_CSS: true

储存设置

可以选择本地储存还是使用S3标准协议的对象储存来保存附件.

SMTP设置

# Default: ""
smtp-host: ""
# Default: 0
smtp-port: 0
# Default: ""
smtp-username: ""
# Default: ""
smtp-password: ""
# Default: ""
smtp-from: ""

以此

GTS_SMTP_HOST:
GTS_SMTP_PORT:
GTS_SMTP_USERNAME:
GTS_SMTP_PASSWORD:
GTS_SMTP_FROM:

字母全部大写,前面加上GTS,-一律写为_即可


作为知名的 菩萨Cloudflare深受大家的喜爱,其中 worker 也让人摸索出了可以科学上网的方法

准备工作

  • 一个顶级域名
  • 一个Cloudflare账号

使用方法

添加站点,把域名解析在cloudflare

在worker中创建Worker

填写名称,点击部署

部署成功,点击编辑代码

填入以下代码,
注意,修改userID 代码第七行
只需在Windows系统中 按下 "Win + R", 键入

Powershell -NoExit -Command "[guid]::NewGuid()"

即可生成userID

// <!--GAMFC-->version base on commit 43fad05dcdae3b723c53c226f8181fc5bd47223e, time is 2023-06-22 15:20:02 UTC<!--GAMFC-END-->.
// @ts-ignore
import { connect } from 'cloudflare:sockets';

// How to generate your own UUID:
// [Windows] Press "Win + R", input cmd and run:  Powershell -NoExit -Command "[guid]::NewGuid()"
let userID = '6f08a7a9-bfde-4df6-920d-877a08d63f32';

let proxyIP = '103.200.112.108';


if (!isValidUUID(userID)) {
    throw new Error('uuid is not valid');
}

export default {
    /**
     * @param {import("@cloudflare/workers-types").Request} request
     * @param {{UUID: string, PROXYIP: string}} env
     * @param {import("@cloudflare/workers-types").ExecutionContext} ctx
     * @returns {Promise<Response>}
     */
    async fetch(request, env, ctx) {
        try {
            userID = env.UUID || userID;
            proxyIP = env.PROXYIP || proxyIP;
            const upgradeHeader = request.headers.get('Upgrade');
            if (!upgradeHeader || upgradeHeader !== 'websocket') {
                const url = new URL(request.url);
                switch (url.pathname) {
                    case '/':
                        return new Response(JSON.stringify(request.cf), { status: 200 });
                    case `/${userID}`: {
                        const vlessConfig = getVLESSConfig(userID, request.headers.get('Host'));
                        return new Response(`${vlessConfig}`, {
                            status: 200,
                            headers: {
                                "Content-Type": "text/plain;charset=utf-8",
                            }
                        });
                    }
                    default:
                        return new Response('Not found', { status: 404 });
                }
            } else {
                return await vlessOverWSHandler(request);
            }
        } catch (err) {
            /** @type {Error} */ let e = err;
            return new Response(e.toString());
        }
    },
};
/**
 * 
 * @param {import("@cloudflare/workers-types").Request} request
 */
async function vlessOverWSHandler(request) {

    /** @type {import("@cloudflare/workers-types").WebSocket[]} */
    // @ts-ignore
    const webSocketPair = new WebSocketPair();
    const [client, webSocket] = Object.values(webSocketPair);

    webSocket.accept();

    let address = '';
    let portWithRandomLog = '';
    const log = (/** @type {string} */ info, /** @type {string | undefined} */ event) => {
        console.log(`[${address}:${portWithRandomLog}] ${info}`, event || '');
    };
    const earlyDataHeader = request.headers.get('sec-websocket-protocol') || '';

    const readableWebSocketStream = makeReadableWebSocketStream(webSocket, earlyDataHeader, log);

    /** @type {{ value: import("@cloudflare/workers-types").Socket | null}}*/
    let remoteSocketWapper = {
        value: null,
    };
    let udpStreamWrite = null;
    let isDns = false;

    // ws --> remote
    readableWebSocketStream.pipeTo(new WritableStream({
        async write(chunk, controller) {
            if (isDns && udpStreamWrite) {
                return udpStreamWrite(chunk);
            }
            if (remoteSocketWapper.value) {
                const writer = remoteSocketWapper.value.writable.getWriter()
                await writer.write(chunk);
                writer.releaseLock();
                return;
            }

            const {
                hasError,
                message,
                portRemote = 443,
                addressRemote = '',
                rawDataIndex,
                vlessVersion = new Uint8Array([0, 0]),
                isUDP,
            } = processVlessHeader(chunk, userID);
            address = addressRemote;
            portWithRandomLog = `${portRemote}--${Math.random()} ${isUDP ? 'udp ' : 'tcp '
                } `;
            if (hasError) {
                // controller.error(message);
                throw new Error(message); // cf seems has bug, controller.error will not end stream
                // webSocket.close(1000, message);
                return;
            }
            // if UDP but port not DNS port, close it
            if (isUDP) {
                if (portRemote === 53) {
                    isDns = true;
                } else {
                    // controller.error('UDP proxy only enable for DNS which is port 53');
                    throw new Error('UDP proxy only enable for DNS which is port 53'); // cf seems has bug, controller.error will not end stream
                    return;
                }
            }
            // ["version", "附加信息长度 N"]
            const vlessResponseHeader = new Uint8Array([vlessVersion[0], 0]);
            const rawClientData = chunk.slice(rawDataIndex);

            // TODO: support udp here when cf runtime has udp support
            if (isDns) {
                const { write } = await handleUDPOutBound(webSocket, vlessResponseHeader, log);
                udpStreamWrite = write;
                udpStreamWrite(rawClientData);
                return;
            }
            handleTCPOutBound(remoteSocketWapper, addressRemote, portRemote, rawClientData, webSocket, vlessResponseHeader, log);
        },
        close() {
            log(`readableWebSocketStream is close`);
        },
        abort(reason) {
            log(`readableWebSocketStream is abort`, JSON.stringify(reason));
        },
    })).catch((err) => {
        log('readableWebSocketStream pipeTo error', err);
    });

    return new Response(null, {
        status: 101,
        // @ts-ignore
        webSocket: client,
    });
}

/**
 * Handles outbound TCP connections.
 *
 * @param {any} remoteSocket 
 * @param {string} addressRemote The remote address to connect to.
 * @param {number} portRemote The remote port to connect to.
 * @param {Uint8Array} rawClientData The raw client data to write.
 * @param {import("@cloudflare/workers-types").WebSocket} webSocket The WebSocket to pass the remote socket to.
 * @param {Uint8Array} vlessResponseHeader The VLESS response header.
 * @param {function} log The logging function.
 * @returns {Promise<void>} The remote socket.
 */
async function handleTCPOutBound(remoteSocket, addressRemote, portRemote, rawClientData, webSocket, vlessResponseHeader, log,) {
    async function connectAndWrite(address, port) {
        /** @type {import("@cloudflare/workers-types").Socket} */
        const tcpSocket = connect({
            hostname: address,
            port: port,
        });
        remoteSocket.value = tcpSocket;
        log(`connected to ${address}:${port}`);
        const writer = tcpSocket.writable.getWriter();
        await writer.write(rawClientData); // first write, nomal is tls client hello
        writer.releaseLock();
        return tcpSocket;
    }

    // if the cf connect tcp socket have no incoming data, we retry to redirect ip
    async function retry() {
        const tcpSocket = await connectAndWrite(proxyIP || addressRemote, portRemote)
        // no matter retry success or not, close websocket
        tcpSocket.closed.catch(error => {
            console.log('retry tcpSocket closed error', error);
        }).finally(() => {
            safeCloseWebSocket(webSocket);
        })
        remoteSocketToWS(tcpSocket, webSocket, vlessResponseHeader, null, log);
    }

    const tcpSocket = await connectAndWrite(addressRemote, portRemote);

    // when remoteSocket is ready, pass to websocket
    // remote--> ws
    remoteSocketToWS(tcpSocket, webSocket, vlessResponseHeader, retry, log);
}

/**
 * 
 * @param {import("@cloudflare/workers-types").WebSocket} webSocketServer
 * @param {string} earlyDataHeader for ws 0rtt
 * @param {(info: string)=> void} log for ws 0rtt
 */
function makeReadableWebSocketStream(webSocketServer, earlyDataHeader, log) {
    let readableStreamCancel = false;
    const stream = new ReadableStream({
        start(controller) {
            webSocketServer.addEventListener('message', (event) => {
                if (readableStreamCancel) {
                    return;
                }
                const message = event.data;
                controller.enqueue(message);
            });

            // The event means that the client closed the client -> server stream.
            // However, the server -> client stream is still open until you call close() on the server side.
            // The WebSocket protocol says that a separate close message must be sent in each direction to fully close the socket.
            webSocketServer.addEventListener('close', () => {
                // client send close, need close server
                // if stream is cancel, skip controller.close
                safeCloseWebSocket(webSocketServer);
                if (readableStreamCancel) {
                    return;
                }
                controller.close();
            }
            );
            webSocketServer.addEventListener('error', (err) => {
                log('webSocketServer has error');
                controller.error(err);
            }
            );
            // for ws 0rtt
            const { earlyData, error } = base64ToArrayBuffer(earlyDataHeader);
            if (error) {
                controller.error(error);
            } else if (earlyData) {
                controller.enqueue(earlyData);
            }
        },

        pull(controller) {
            // if ws can stop read if stream is full, we can implement backpressure
            // https://streams.spec.whatwg.org/#example-rs-push-backpressure
        },
        cancel(reason) {
            // 1. pipe WritableStream has error, this cancel will called, so ws handle server close into here
            // 2. if readableStream is cancel, all controller.close/enqueue need skip,
            // 3. but from testing controller.error still work even if readableStream is cancel
            if (readableStreamCancel) {
                return;
            }
            log(`ReadableStream was canceled, due to ${reason}`)
            readableStreamCancel = true;
            safeCloseWebSocket(webSocketServer);
        }
    });

    return stream;

}

// https://xtls.github.io/development/protocols/vless.html
// https://github.com/zizifn/excalidraw-backup/blob/main/v2ray-protocol.excalidraw

/**
 * 
 * @param { ArrayBuffer} vlessBuffer 
 * @param {string} userID 
 * @returns 
 */
function processVlessHeader(
    vlessBuffer,
    userID
) {
    if (vlessBuffer.byteLength < 24) {
        return {
            hasError: true,
            message: 'invalid data',
        };
    }
    const version = new Uint8Array(vlessBuffer.slice(0, 1));
    let isValidUser = false;
    let isUDP = false;
    if (stringify(new Uint8Array(vlessBuffer.slice(1, 17))) === userID) {
        isValidUser = true;
    }
    if (!isValidUser) {
        return {
            hasError: true,
            message: 'invalid user',
        };
    }

    const optLength = new Uint8Array(vlessBuffer.slice(17, 18))[0];
    //skip opt for now

    const command = new Uint8Array(
        vlessBuffer.slice(18 + optLength, 18 + optLength + 1)
    )[0];

    // 0x01 TCP
    // 0x02 UDP
    // 0x03 MUX
    if (command === 1) {
    } else if (command === 2) {
        isUDP = true;
    } else {
        return {
            hasError: true,
            message: `command ${command} is not support, command 01-tcp,02-udp,03-mux`,
        };
    }
    const portIndex = 18 + optLength + 1;
    const portBuffer = vlessBuffer.slice(portIndex, portIndex + 2);
    // port is big-Endian in raw data etc 80 == 0x005d
    const portRemote = new DataView(portBuffer).getUint16(0);

    let addressIndex = portIndex + 2;
    const addressBuffer = new Uint8Array(
        vlessBuffer.slice(addressIndex, addressIndex + 1)
    );

    // 1--> ipv4  addressLength =4
    // 2--> domain name addressLength=addressBuffer[1]
    // 3--> ipv6  addressLength =16
    const addressType = addressBuffer[0];
    let addressLength = 0;
    let addressValueIndex = addressIndex + 1;
    let addressValue = '';
    switch (addressType) {
        case 1:
            addressLength = 4;
            addressValue = new Uint8Array(
                vlessBuffer.slice(addressValueIndex, addressValueIndex + addressLength)
            ).join('.');
            break;
        case 2:
            addressLength = new Uint8Array(
                vlessBuffer.slice(addressValueIndex, addressValueIndex + 1)
            )[0];
            addressValueIndex += 1;
            addressValue = new TextDecoder().decode(
                vlessBuffer.slice(addressValueIndex, addressValueIndex + addressLength)
            );
            break;
        case 3:
            addressLength = 16;
            const dataView = new DataView(
                vlessBuffer.slice(addressValueIndex, addressValueIndex + addressLength)
            );
            // 2001:0db8:85a3:0000:0000:8a2e:0370:7334
            const ipv6 = [];
            for (let i = 0; i < 8; i++) {
                ipv6.push(dataView.getUint16(i * 2).toString(16));
            }
            addressValue = ipv6.join(':');
            // seems no need add [] for ipv6
            break;
        default:
            return {
                hasError: true,
                message: `invild  addressType is ${addressType}`,
            };
    }
    if (!addressValue) {
        return {
            hasError: true,
            message: `addressValue is empty, addressType is ${addressType}`,
        };
    }

    return {
        hasError: false,
        addressRemote: addressValue,
        addressType,
        portRemote,
        rawDataIndex: addressValueIndex + addressLength,
        vlessVersion: version,
        isUDP,
    };
}


/**
 * 
 * @param {import("@cloudflare/workers-types").Socket} remoteSocket 
 * @param {import("@cloudflare/workers-types").WebSocket} webSocket 
 * @param {ArrayBuffer} vlessResponseHeader 
 * @param {(() => Promise<void>) | null} retry
 * @param {*} log 
 */
async function remoteSocketToWS(remoteSocket, webSocket, vlessResponseHeader, retry, log) {
    // remote--> ws
    let remoteChunkCount = 0;
    let chunks = [];
    /** @type {ArrayBuffer | null} */
    let vlessHeader = vlessResponseHeader;
    let hasIncomingData = false; // check if remoteSocket has incoming data
    await remoteSocket.readable
        .pipeTo(
            new WritableStream({
                start() {
                },
                /**
                 * 
                 * @param {Uint8Array} chunk 
                 * @param {*} controller 
                 */
                async write(chunk, controller) {
                    hasIncomingData = true;
                    // remoteChunkCount++;
                    if (webSocket.readyState !== WS_READY_STATE_OPEN) {
                        controller.error(
                            'webSocket.readyState is not open, maybe close'
                        );
                    }
                    if (vlessHeader) {
                        webSocket.send(await new Blob([vlessHeader, chunk]).arrayBuffer());
                        vlessHeader = null;
                    } else {
                        // seems no need rate limit this, CF seems fix this??..
                        // if (remoteChunkCount > 20000) {
                        //     // cf one package is 4096 byte(4kb),  4096 * 20000 = 80M
                        //     await delay(1);
                        // }
                        webSocket.send(chunk);
                    }
                },
                close() {
                    log(`remoteConnection!.readable is close with hasIncomingData is ${hasIncomingData}`);
                    // safeCloseWebSocket(webSocket); // no need server close websocket frist for some case will casue HTTP ERR_CONTENT_LENGTH_MISMATCH issue, client will send close event anyway.
                },
                abort(reason) {
                    console.error(`remoteConnection!.readable abort`, reason);
                },
            })
        )
        .catch((error) => {
            console.error(
                `remoteSocketToWS has exception `,
                error.stack || error
            );
            safeCloseWebSocket(webSocket);
        });

    // seems is cf connect socket have error,
    // 1. Socket.closed will have error
    // 2. Socket.readable will be close without any data coming
    if (hasIncomingData === false && retry) {
        log(`retry`)
        retry();
    }
}

/**
 * 
 * @param {string} base64Str 
 * @returns 
 */
function base64ToArrayBuffer(base64Str) {
    if (!base64Str) {
        return { error: null };
    }
    try {
        // go use modified Base64 for URL rfc4648 which js atob not support
        base64Str = base64Str.replace(/-/g, '+').replace(/_/g, '/');
        const decode = atob(base64Str);
        const arryBuffer = Uint8Array.from(decode, (c) => c.charCodeAt(0));
        return { earlyData: arryBuffer.buffer, error: null };
    } catch (error) {
        return { error };
    }
}

/**
 * This is not real UUID validation
 * @param {string} uuid 
 */
function isValidUUID(uuid) {
    const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[4][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
    return uuidRegex.test(uuid);
}

const WS_READY_STATE_OPEN = 1;
const WS_READY_STATE_CLOSING = 2;
/**
 * Normally, WebSocket will not has exceptions when close.
 * @param {import("@cloudflare/workers-types").WebSocket} socket
 */
function safeCloseWebSocket(socket) {
    try {
        if (socket.readyState === WS_READY_STATE_OPEN || socket.readyState === WS_READY_STATE_CLOSING) {
            socket.close();
        }
    } catch (error) {
        console.error('safeCloseWebSocket error', error);
    }
}

const byteToHex = [];
for (let i = 0; i < 256; ++i) {
    byteToHex.push((i + 256).toString(16).slice(1));
}
function unsafeStringify(arr, offset = 0) {
    return (byteToHex[arr[offset + 0]] + byteToHex[arr[offset + 1]] + byteToHex[arr[offset + 2]] + byteToHex[arr[offset + 3]] + "-" + byteToHex[arr[offset + 4]] + byteToHex[arr[offset + 5]] + "-" + byteToHex[arr[offset + 6]] + byteToHex[arr[offset + 7]] + "-" + byteToHex[arr[offset + 8]] + byteToHex[arr[offset + 9]] + "-" + byteToHex[arr[offset + 10]] + byteToHex[arr[offset + 11]] + byteToHex[arr[offset + 12]] + byteToHex[arr[offset + 13]] + byteToHex[arr[offset + 14]] + byteToHex[arr[offset + 15]]).toLowerCase();
}
function stringify(arr, offset = 0) {
    const uuid = unsafeStringify(arr, offset);
    if (!isValidUUID(uuid)) {
        throw TypeError("Stringified UUID is invalid");
    }
    return uuid;
}


/**
 * 
 * @param {import("@cloudflare/workers-types").WebSocket} webSocket 
 * @param {ArrayBuffer} vlessResponseHeader 
 * @param {(string)=> void} log 
 */
async function handleUDPOutBound(webSocket, vlessResponseHeader, log) {

    let isVlessHeaderSent = false;
    const transformStream = new TransformStream({
        start(controller) {

        },
        transform(chunk, controller) {
            // udp message 2 byte is the the length of udp data
            // TODO: this should have bug, beacsue maybe udp chunk can be in two websocket message
            for (let index = 0; index < chunk.byteLength;) {
                const lengthBuffer = chunk.slice(index, index + 2);
                const udpPakcetLength = new DataView(lengthBuffer).getUint16(0);
                const udpData = new Uint8Array(
                    chunk.slice(index + 2, index + 2 + udpPakcetLength)
                );
                index = index + 2 + udpPakcetLength;
                controller.enqueue(udpData);
            }
        },
        flush(controller) {
        }
    });

    // only handle dns udp for now
    transformStream.readable.pipeTo(new WritableStream({
        async write(chunk) {
            const resp = await fetch('https://1.1.1.1/dns-query',
                {
                    method: 'POST',
                    headers: {
                        'content-type': 'application/dns-message',
                    },
                    body: chunk,
                })
            const dnsQueryResult = await resp.arrayBuffer();
            const udpSize = dnsQueryResult.byteLength;
            // console.log([...new Uint8Array(dnsQueryResult)].map((x) => x.toString(16)));
            const udpSizeBuffer = new Uint8Array([(udpSize >> 8) & 0xff, udpSize & 0xff]);
            if (webSocket.readyState === WS_READY_STATE_OPEN) {
                log(`doh success and dns message length is ${udpSize}`);
                if (isVlessHeaderSent) {
                    webSocket.send(await new Blob([udpSizeBuffer, dnsQueryResult]).arrayBuffer());
                } else {
                    webSocket.send(await new Blob([vlessResponseHeader, udpSizeBuffer, dnsQueryResult]).arrayBuffer());
                    isVlessHeaderSent = true;
                }
            }
        }
    })).catch((error) => {
        log('dns udp has error' + error)
    });

    const writer = transformStream.writable.getWriter();

    return {
        /**
         * 
         * @param {Uint8Array} chunk 
         */
        write(chunk) {
            writer.write(chunk);
        }
    };
}

/**
 * 
 * @param {string} userID 
 * @param {string | null} hostName
 * @returns {string}
 */
function getVLESSConfig(userID, hostName) {
    const vlessMain = `vless://${userID}@${hostName}:443?encryption=none&security=tls&sni=${hostName}&fp=randomized&type=ws&host=${hostName}&path=%2F%3Fed%3D2048#${hostName}`
    return `
################################################################
v2ray
---------------------------------------------------------------
${vlessMain}
---------------------------------------------------------------
################################################################
clash-meta
---------------------------------------------------------------
- type: vless
  name: ${hostName}
  server: ${hostName}
  port: 443
  uuid: ${userID}
  network: ws
  tls: true
  udp: false
  sni: ${hostName}
  client-fingerprint: chrome
  ws-opts:
    path: "/?ed=2048"
    headers:
      host: ${hostName}
---------------------------------------------------------------
################################################################
`;
}

获得VLESS

部署成功之后,绑定一个域名

访问 https://域名/uuid 即可看到vless链接信息


前言

起因是wordpress会自动安装一个名为wpcode的插件,每次删除卸载之后仍会自动安装,有一次直接导致网站无法访问,百度不得其解.重装也不起效.
于是弃之.
转投typecho的怀抱.

环境

PHP 8.2
Mysql 5.5

转换

先安装Typecho .
1.下载插件 WordpressToTypecho,配置好数据库的相关设置,
参考 https://docs.typecho.org/import
2.点击转换成功之后,把附件资料从WordPresswp-content/uploads目录下全部移动到Typechousr/uploads目录下,保持目录结构不变。
3.在数据库中执行

update typecho_contents set text=replace(text,'wp-content/uploads','usr/uploads')

4.设置伪静态,保持与wordpress相同的链接结构

使用

插件

1.AAEditor Markdown编辑器功能强大
2.AISummary 使用ChatGPT或者Gemini生成文章摘要.开箱即用.
看到木木老师有分享这个网站,可以使用我的aff注册,谢谢
https://burn.hair/register?aff=a8fr

新用户赠送 2500000 token,约等于 5 USD
邀请用户注册,双方各得 1000000 token,约等于 2 USD

3.Links 友情链接插件
4.ShortLinks 把外链转换成内链
php8.2报错需修改Plugin.php

//$str = str_replace(array("\r\n", "\r", "\n"), "|", $textarea);

// 检查 $textarea 是否为 null,如果是,设为一个空字符串
$textarea = $textarea ?? '';
// 然后你可以对其使用 str_replace
$str = str_replace(array("\r\n", "\r", "\n"), "|", $textarea);

5.SiteMap 网站地图
6.WordsCounter 字数统计
7.cosUploadV5 腾讯COS对象储存

主题

主题使用开源的 Matcha
1.修改
想让首页显示的摘要为AI生成的摘要
matcha/includes/posts.php中的

<div class="post-content" itemprop="articleBody">
<?php Matcha::excerpt($this); ?>
</div>

替换为

<div class="post-content" itemprop="articleBody">
    <?php
    // 判断是否存在自定义字段summary并输出,否则输出自动生成的摘要
    if($this->fields->summary){
        echo $this->fields->summary;
    } else {
        Matcha::excerpt($this);
    }
    ?>
</div>

主题使用Sunny Lite!时,修改article.php

  <?php echo get_Abstract($this,300); ?>

替换为

<?php echo $this->fields->summary;?>

2.显示页面加载时间
在主题的 Functions.php 中加入以下代码

/**
* 页面加载时间
*/
function timer_start() {
global $timestart;
$mtime = explode( ' ', microtime() );
$timestart = $mtime[1] + $mtime[0];
return true;
}
timer_start();
function timer_stop( $display = 0, $precision = 3 ) {
global $timestart, $timeend;
$mtime = explode( ' ', microtime() );
$timeend = $mtime[1] + $mtime[0];
$timetotal = number_format( $timeend - $timestart, $precision );
$r = $timetotal < 1 ? $timetotal * 1000 . " ms" : $timetotal . " s";
if ( $display ) {
echo $r;
}
return $r;
}

footer.php 合适位置插入

<?php echo timer_stop();?>

或者

<a href="javascript:(0)" id="pagetimes"></a>
<script>
document.getElementById('pagetimes').innerHTML = '<img src="https://img.shields.io/badge/页面加载耗时:-<?php echo timer_stop();?>-green">';
</script>

3.使用memos API增加说说页面
把 page-memos.php放在模板matcha

然后在后台新建独立页面-选择模板
添加字段中自定义
memos= memos地址 默认为https://memos.imsun.org
memostag = 使用的tag 可以选择默认,默认为说说
creatorId = memos的ID,默认为1
memosname = 显示的昵称
memosava = 显示的头像

下载地址

4.关闭非中文语系的评论
查找comments.php文件中

<?php if($this->allow('comment')): ?>

替换为

<?php if($this->allow('comment') && stripos($_SERVER['HTTP_ACCEPT_LANGUAGE'], 'zh') > -1): ?>

5.打赏
根据插件Donate提取

<!--打赏  -->
<script type="text/javascript" src="https://blogcdn.loliko.cn/donate/index_wx.js?121"></script>
<link rel="stylesheet" type="text/css" href="https://blogcdn.loliko.cn/donate/style_wx.css?121" />
<div class="donate-panel"> 
  <div id="donate-btn">赏</div> 
  <div id="qrcode-panel" style="display: none;"> 
    <div class="qrcode-body"> 
      <div class="donate-memo"> 
      <span id="donate-close">关闭</span> 
    </div> 
    <div class="donate-qrpay"> 
     <img id="wxqr" src="https://blogcdn.loliko.cn/donate/2in12.png" /> 
    </div> 
     </div> 
   </div> 
</div> 

参考
1.https://docs.typecho.org/import