标签 联邦宇宙 下的文章

前言

使用 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 的绑定域名,可自行更改


[article id="1660"]

后续

如何获取relay中继服务器的列表呢
参考项目 https://github.com/dragonfly-club/dragon-relay
这个项目呢 是把列表生成自定义的html页面,不够灵活,所以我改了一下
用以生成json数据

我原本的设想是通过dockerfile重新构建一个docker镜像,但是一想又嫌麻烦,所以只好通过曲线救国了///

使用方法

python

gen-member-list.py的内容

#!/usr/bin/python3

import logging
import requests
import base64
import json
from collections import Counter
from subprocess import Popen, PIPE
import shutil

outfile = 'output.json'
stats_file = 'stats.json'
USER_AGENT = 'Mozilla/5.0 (X11; Linux x86_64; rv:109.0) Gecko/20100101 Firefox/109.0 (https://relay.jiong.us)'
TIMEOUT = 4

instance_ids = set()

def setup_logging():
    logger = logging.getLogger(__name__)
    logger.setLevel(logging.INFO)
    log_handler = logging.FileHandler('gen-member-list.log')
    log_handler.setLevel(logging.INFO)
    log_format = logging.Formatter('%(asctime)s [%(levelname)s] %(message)s')
    log_handler.setFormatter(log_format)
    logger.addHandler(log_handler)
    return logger

logger = setup_logging()

def get_redis_cli_path():
    redis_cli = shutil.which('redis-cli')
    if redis_cli:
        return redis_cli
    else:
        raise FileNotFoundError("redis-cli not found in PATH")

def read_redis_keys():
    redis_cli = get_redis_cli_path()
    cmd = [redis_cli]
    cmdin = 'KEYS relay:subscription:*'.encode('utf-8')
    p = Popen(cmd, stdin=PIPE, stdout=PIPE, stderr=PIPE)
    return p.communicate(input=cmdin)[0].decode('utf-8')

def generate_instance_id(page):
    uid = []
    fields = ['uri', 'email', 'name', 'hcaptchaSiteKey']
    for field in fields:
        try:
            uid.append(str(page.get(field, '')))
        except AttributeError:
            pass

    try:
        if page.get('contact_account'):
            uid.append(str(page['contact_account'].get('id', '')))
            uid.append(str(page['contact_account'].get('username', '')))
    except AttributeError:
        pass

    return '_'.join(filter(None, uid))

def fetch_favicon(domain):
    try:
        favicon_url = f"https://{domain}/favicon.ico"
        response = requests.get(favicon_url, timeout=TIMEOUT)
        if response.status_code == 200:
            return base64.b64encode(response.content).decode('utf-8')
    except Exception as e:
        logger.warning(f"Failed to fetch favicon for {domain}: {str(e)}")
    return ""

def try_nodeinfo(headers, domain, timeout):
    nodeinfo_url = f"https://{domain}/.well-known/nodeinfo"
    response = requests.get(nodeinfo_url, headers=headers, timeout=timeout)
    nodeinfo_link = response.json()['links'][0]['href']

    response = requests.get(nodeinfo_link, headers=headers, timeout=timeout)
    nodeinfo = response.json()

    software = nodeinfo['software']['name']
    version = nodeinfo['software']['version']
    stats = nodeinfo['usage']

    fav_md = fetch_favicon(domain)
    title = nodeinfo["metadata"].get("nodeName", domain.split('.')[0].capitalize())

    uid = generate_instance_id(nodeinfo)

    json_line = {
        'favicon': fav_md,
        'title': title,
        'domain': domain,
        'users': stats['users']['total'],
        'posts': stats.get('localPosts', 0),
        'software': software,
        'version': version,
        'instances': nodeinfo.get('metadata', {}).get('federation', {}).get('domainCount', 0)
    }

    return json_line, uid

def try_mastodon(headers, domain, timeout):
    instance_url = f"https://{domain}/api/v1/instance"
    response = requests.get(instance_url, headers=headers, timeout=timeout)
    instance_info = response.json()

    stats_url = f"https://{domain}/api/v1/instance/peers"
    response = requests.get(stats_url, headers=headers, timeout=timeout)
    peers = response.json()

    fav_md = fetch_favicon(domain)
    title = instance_info['title']
    version = instance_info['version']

    uid = generate_instance_id(instance_info)

    json_line = {
        'favicon': fav_md,
        'title': title,
        'domain': domain,
        'users': instance_info['stats']['user_count'],
        'statuses': instance_info['stats']['status_count'],
        'instances': len(peers),
        'version': version,
        'software': 'mastodon'
    }

    return json_line, uid

def try_misskey(headers, domain, timeout):
    meta_url = f"https://{domain}/api/meta"
    response = requests.post(meta_url, headers=headers, timeout=timeout)
    meta_info = response.json()

    stats_url = f"https://{domain}/api/stats"
    response = requests.post(stats_url, headers=headers, timeout=timeout)
    stats = response.json()

    fav_md = fetch_favicon(domain)
    title = meta_info['name']
    version = meta_info['version']

    uid = generate_instance_id(meta_info)

    json_line = {
        'favicon': fav_md,
        'title': title,
        'domain': domain,
        'users': stats['originalUsersCount'],
        'notes': stats['originalNotesCount'],
        'instances': stats['instances'],
        'version': version,
        'software': 'misskey'
    }

    return json_line, uid

def generate_list():
    json_list = []
    all_domains = [line.split('subscription:')[-1] for line in read_redis_keys().split('\n') if line and 'subscription' in line]
    logger.info(f"Total domains from Redis: {len(all_domains)}")

    success_count = 0
    failure_count = 0
    software_counter = Counter()
    interaction_stats = {}

    for domain in all_domains:
        logger.info(f"Processing domain: {domain}")

        headers = {
            'User-Agent': USER_AGENT
        }

        json_line = {'domain': domain, 'status': 'Stats Unavailable'}
        uid = None
        success = False

        for try_function in [try_mastodon, try_misskey, try_nodeinfo]:
            try:
                json_line, uid = try_function(headers, domain, TIMEOUT)
                logger.info(f"Successfully fetched stats for {domain} using {try_function.__name__}")
                success = True
                break
            except Exception as e:
                logger.warning(f"Failed to fetch stats for {domain} using {try_function.__name__}: {str(e)}")

        if success:
            success_count += 1
            software_counter[json_line.get('software', 'Unknown')] += 1

            interaction_count = json_line.get('statuses', 0) or json_line.get('notes', 0) or json_line.get('posts', 0)
            interaction_stats[domain] = interaction_count

            logger.info(f"Instances count for {domain}: {json_line.get('instances', 0)}")
        else:
            failure_count += 1

        if uid and uid in instance_ids:
            logger.info(f"Skipped duplicate domain {domain} with uid {uid}")
            continue

        if uid:
            instance_ids.add(uid)
        json_list.append(json_line)
        logger.info(f"Added {domain} to the list")

    logger.info(f"Total instances processed: {len(json_list)}")
    logger.info(f"Successful instances: {success_count}")
    logger.info(f"Failed instances: {failure_count}")
    logger.info(f"Software distribution: {dict(software_counter)}")

    json_list.sort(key=lambda x: x.get('users', 0), reverse=True)

    stats = {
        "total_instances": len(json_list),
        "successful_instances": success_count,
        "failed_instances": failure_count,
        "software_distribution": dict(software_counter),
        "interaction_stats": interaction_stats
    }

    with open(stats_file, 'w') as f:
        json.dump(stats, f, indent=2)

    return json_list

if __name__ == "__main__":
    logger.info('Started generating member list.')
    sub_list = generate_list()
    with open(outfile, 'w') as f:
        json.dump(sub_list, f, indent=2)
    logger.info('Write new page template done.')

bash脚本

update-list.sh的内容

#!/bin/sh

if [ ! -f /tmp/setup_done ]; then #如果存在安装缓存则跳过,重启容器会重新安装依赖
    apk add python3 py3-pip py3-requests #安装python
    apk add tzdata #修正时区
    cp /usr/share/zoneinfo/Asia/Shanghai /etc/localtime
    echo "Asia/Shanghai" > /etc/timezone
    touch /tmp/setup_done
fi

cd /relay/

./gen-member-list.py || exit 1;

exit 0;

修改Dockercompose.yaml

这一步就是映射本地脚本到docker容器中

services:
  redis:
    restart: always
    image: redis:alpine
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
    volumes:
      - "./redisdata:/data"
      - "./relay:/relay"

  worker:
    container_name: worker
    build: .
    image: yukimochi/activity-relay
    working_dir: /var/lib/relay
    restart: always
    init: true
    command: relay worker
    volumes:
      - "./actor.pem:/var/lib/relay/actor.pem"
      - "./config.yml:/var/lib/relay/config.yml"
    depends_on:
      - redis

  server:
    container_name: relay
    build: .
    image: yukimochi/activity-relay
    working_dir: /var/lib/relay
    restart: always
    init: true
    ports:
      - "8080:8080"
    command: relay server
    volumes:
      - "./actor.pem:/var/lib/relay/actor.pem"
      - "./config.yml:/var/lib/relay/config.yml"
    depends_on:
      - redis

然后把上面两个脚本都丢进relay的文件夹

定时任务

使用宝塔或者系统的计划任务
activity-relay-redis-1为redis的容器名,执行bash

docker exec  activity-relay-redis-1  /bin/sh /relay/update-list.sh

演示

https://relay.jiong.us