前言

最近查看友联的时候发现有挺多友联无法访问的情况,静态博客每次要更新又需要改,所以就直接通过朋友圈项目里面去检测友联的网站是否可以访问。不能访问单独拉出去 亮相

同时我也修改了朋友圈钉钉的新通知,之前是通知的无法提取文章的网站 现在改为网站异常的链接 参考钉钉获取朋友圈执行日志, 完整朋友圈部署请移步朋友圈部署

教程仅适用于数据库为 sqlite 其他数据库请自行模仿修改

models

找到项目下的 hexo_circle_of_friends文件夹下的models.py 直接替换成下面代码

主要在Friend里面增加了 loss = Column(BOOLEAN)

# -*- coding:utf-8 -*-
# Author:yyyz
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy import Column, Integer, String, BOOLEAN, DateTime, TEXT
from datetime import datetime, timedelta

Model = declarative_base()


class AbstractBase(Model):
__abstract__ = True

def to_dict(self):
model_dict = dict(self.__dict__)
del model_dict['_sa_instance_state']
return model_dict


class Friend(AbstractBase):
__tablename__ = 'friends'
id = Column(Integer, primary_key=True, autoincrement=True)
name = Column(String(256))
link = Column(String(1024))
avatar = Column(String(1024))
error = Column(BOOLEAN)
loss = Column(BOOLEAN)
createAt = Column(DateTime, default=datetime.utcnow() + timedelta(hours=8))


class Post(AbstractBase):
__tablename__ = 'posts'
id = Column(Integer, primary_key=True, autoincrement=True)
title = Column(String(256))
created = Column(String(256))
updated = Column(String(256))
link = Column(String(1024))
author = Column(String(256))
avatar = Column(String(1024))
rule = Column(String(256))
createAt = Column(DateTime, default=datetime.utcnow() + timedelta(hours=8))


class Auth(AbstractBase):
__tablename__ = 'auth'
id = Column(Integer, primary_key=True, autoincrement=True)
password = Column(String(1024))


class Secret(AbstractBase):
__tablename__ = 'secret'
id = Column(Integer, primary_key=True, autoincrement=True)
secret_key = Column(String(1024))

sql_pipe

找到项目下的 hexo_circle_of_friends文件夹下的pipelines文件夹下面的sql_pipe.py 直接替换成下面代码

主要对friendlist_push函数进行更改 记录请求异常的好友

# -*- coding:utf-8 -*-
# Author:yyyz
import os
import re
import sys
from urllib import parse
from .. import models
from ..utils import baselogger, project
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker, scoped_session
from datetime import datetime, timedelta
import requests
from requests.exceptions import RequestException

today = (datetime.utcnow() + timedelta(hours=8)).strftime('%Y-%m-%d %H:%M:%S')
logger = baselogger.get_logger(__name__)


class SQLPipeline:
def __init__(self):
self.userdata = []
self.nonerror_data = set() # 能够根据友链link获取到文章的人

def open_spider(self, spider):
settings = spider.settings
base_path = project.get_base_path()
db = settings["DATABASE"]
if settings["DEBUG"]:
if db == "sqlite":
if sys.platform == "win32":
conn = rf"sqlite:///{os.path.join(base_path, 'data.db')}?check_same_thread=False"
else:
conn = f"sqlite:////{os.path.join(base_path, 'data.db')}?check_same_thread=False"
elif db == "mysql":
conn = "mysql+pymysql://%s:%s@%s:3306/%s?charset=utf8mb4" \
% ("root", "123456", "localhost", "test")
else:
raise Exception("SQL连接失败,不支持的数据库!")
else:
if db == "sqlite":
conn = f"sqlite:////{os.path.join(base_path, 'data.db')}?check_same_thread=False"
elif db == "mysql":
conn = f"mysql+pymysql://{os.environ['MYSQL_USERNAME']}:{parse.quote_plus(os.environ['MYSQL_PASSWORD'])}" \
f"@{os.environ['MYSQL_IP']}:{os.environ['MYSQL_PORT']}/{os.environ['MYSQL_DB']}?charset=utf8mb4"
else:
raise Exception("SQL连接失败,不支持的数据库!")
try:
self.engine = create_engine(conn, pool_recycle=-1)
except:
raise Exception("SQL连接失败")
Session = sessionmaker(bind=self.engine)
self.session = scoped_session(Session)

# 创建表
models.Model.metadata.create_all(self.engine)
# 删除friend表
self.session.query(models.Friend).delete()
# 获取post表数据
self.query_post()
logger.info("Initialization complete")

def process_item(self, item, spider):
if "userdata" in item.keys():
li = []
li.append(item["name"])
li.append(item["link"])
li.append(item["img"])
self.userdata.append(li)
# print(item)
return item

if "title" in item.keys():
if item["author"] in self.nonerror_data:
pass
else:
# 未失联的人
self.nonerror_data.add(item["author"])

# print(item)
for query_item in self.query_post_list:
try:
if query_item.link == item["link"]:
item["created"] = min(item['created'], query_item.created)
self.session.query(models.Post).filter_by(link=query_item.link).delete()
except:
pass

self.friendpoor_push(item)

return item

def close_spider(self, spider):
# print(self.nonerror_data)
# print(self.userdata)
settings = spider.settings
self.friendlist_push(settings)
self.outdate_clean(settings["OUTDATE_CLEAN"])
logger.info("----------------------")
logger.info("友链总数 : %d" % self.session.query(models.Friend).count())
logger.info("失联友链数 : %d" % self.session.query(models.Friend).filter_by(error=True).count())
logger.info("站点异常友链数 : %d" % self.session.query(models.Friend).filter_by(loss=True).count())
logger.info("共 %d 篇文章" % self.session.query(models.Post).count())
logger.info("最后运行于:%s" % today)
logger.info("done!")

def query_post(self):
try:
self.query_post_list = self.session.query(models.Post).all()
except:
self.query_post_list = []

def outdate_clean(self, time_limit):
out_date_post = 0
self.query_post()
for query_item in self.query_post_list:
updated = query_item.updated
try:
query_time = datetime.strptime(updated, "%Y-%m-%d")
if (datetime.utcnow() + timedelta(hours=8) - query_time).days > time_limit:
self.session.query(models.Post).filter_by(link=query_item.link).delete()
out_date_post += 1
except:
self.session.query(models.Post).filter_by(link=query_item.link).delete()
out_date_post += 1
self.session.commit()
self.session.close()
# print('\n')
# print('共删除了%s篇文章' % out_date_post)
# print('\n')
# print('-------结束删除规则----------')



def friendlist_push(self, settings):
for user in self.userdata:
friend = models.Friend(
name=user[0],
link=user[1],
avatar=user[2]
)
if user[0] in self.nonerror_data:
# print("未失联的用户")
friend.error = False
friend.loss = False
else:
error = True
if settings["BLOCK_SITE"]:
for url in settings["BLOCK_SITE"]:
if re.match(url, friend.link):
friend.error = False
friend.loss = False
error = False
break
if error:
try:
response = requests.get(friend.link,headers={'User-Agent': 'Mozilla/5.0'}, timeout=5.0)
if response.status_code != 200:
friend.loss = True
except RequestException as e:
friend.loss = True
friend.error = True
logger.error("请求失败,请检查链接: %s" % friend.link)

self.session.add(friend)
self.session.commit()



def friendpoor_push(self, item):
post = models.Post(
title=item['title'],
created=item['created'],
updated=item['updated'],
link=item['link'],
author=item['author'],
avatar=item['avatar'],
rule=item['rule']
)
self.session.add(post)
self.session.commit()

info = f"""\033[1;34m\n——————————————————————————————————————————————————————————————————————————————
{item['author']}\n《{item['title']}》\n文章发布时间:{item['created']}\t\t采取的爬虫规则为:{item['rule']}
——————————————————————————————————————————————————————————————————————————————\033[0m"""
logger.info(info)

api

找到项目下的 api_dependencies/sql/sqlapi.py文件 替换下面内容

# -*- coding:utf-8 -*-

import os
import json
import requests
from fastapi import Depends
from urllib import parse
from jose import JWTError
from hexo_circle_of_friends.utils.project import get_user_settings
from hexo_circle_of_friends.models import Friend, Post, Auth
from sqlalchemy.sql.expression import desc, func
from hexo_circle_of_friends.utils.process_time import time_compare
from api_dependencies.utils.validate_params import start_end_check
from api_dependencies.utils.github_interface import create_or_update_file, get_b64encoded_data
from api_dependencies.sql import db_interface, security
from api_dependencies import format_response, tools, dependencies as dep


async def vercel_update_db():
"""
vercel环境需要上传data.db到github
:return:
"""
# github+vercel将db上传
db_path = "/tmp/data.db"
with open(db_path, "rb") as f:
data = f.read()
gh_access_token = os.environ.get("GH_TOKEN", "")
gh_name = os.environ.get("GH_NAME", "")
gh_email = os.environ.get("GH_EMAIL", "")
repo_name = "hexo-circle-of-friends"
message = "Update data.db"
await create_or_update_file(gh_access_token, gh_name, gh_email, repo_name,
"data.db",
get_b64encoded_data(data), message)


def query_all(li, start: int = 0, end: int = -1, rule: str = "updated"):
try:
session = db_interface.db_init()
article_num = session.query(Post).count()
# 检查start、end的合法性
start, end, message = start_end_check(start, end, article_num)
if message:
return {"message": message}
# 检查rule的合法性
if rule != "created" and rule != "updated":
return {"message": "rule error, please use 'created'/'updated'"}

posts = session.query(Post).order_by(desc(rule)).offset(start).limit(end - start).all()
last_update_time_results = session.query(Post).limit(1000).with_entities(Post.createAt).all()
last_update_time = max(x[0].strftime("%Y-%m-%d %H:%M:%S") for x in last_update_time_results)

friends_num = session.query(Friend).count()
active_num = session.query(Friend).filter_by(error=False).count()
error_num = friends_num - active_num
loss_num = session.query(Friend).filter_by(loss=True).count()

all_friends = session.query(Friend).all()
friends_loss = []
friends_error = []
for friend in all_friends:
item = {
'name': friend.name,
'link': friend.link,
'avatar': friend.avatar
}
if friend.loss:
friends_loss.append(item)
if friend.error:
friends_error.append(item)

data = {}
data['statistical_data'] = {
'friends_num': friends_num,
'active_num': active_num,
'error_num': error_num,
'loss_num': loss_num,
'article_num': article_num,
'last_updated_time': last_update_time
}

post_data = []
for k in range(len(posts)):
item = {'floor': start + k + 1}
for elem in li:
item[elem] = getattr(posts[k], elem)
post_data.append(item)
session.close()
data['article_data'] = post_data
data['friends_error'] = friends_error
data['friends_loss'] = friends_loss
return data
except Exception as e:
return {"message": "服务器内部错误!!! 请联系管理人员!" + str(e)}


def query_friend():
session = db_interface.db_init()
friends = session.query(Friend).limit(1000).all()
session.close()

friend_list_json = []
if friends:
for friend in friends:
item = {
'name': friend.name,
'link': friend.link,
'avatar': friend.avatar
}
friend_list_json.append(item)
else:
# friends为空直接返回
return {"message": "not found"}

return friend_list_json


def query_random_friend(num):
if num < 1:
return {"message": "param 'num' error"}
session = db_interface.db_init()
settings = get_user_settings()
if settings["DATABASE"] == "sqlite":
data: list = session.query(Friend).order_by(
func.random()).limit(num).all()
else:
data: list = session.query(Friend).order_by(
func.rand()).limit(num).all()
session.close()
friend_list_json = []
if data:
for d in data:
itemlist = {
'name': d.name,
'link': d.link,
'avatar': d.avatar
}
friend_list_json.append(itemlist)
else:
# data为空直接返回
return {"message": "not found"}
return friend_list_json[0] if len(friend_list_json) == 1 else friend_list_json


def query_random_post(num):
if num < 1:
return {"message": "param 'num' error"}
session = db_interface.db_init()
settings = get_user_settings()
if settings["DATABASE"] == "sqlite":
data: list = session.query(Post).order_by(
func.random()).limit(num).all()
else:
data: list = session.query(Post).order_by(func.rand()).limit(num).all()
session.close()
post_list_json = []
if data:
for d in data:
itemlist = {
"title": d.title,
"created": d.created,
"updated": d.updated,
"link": d.link,
"author": d.author,
"avatar": d.avatar,
}
post_list_json.append(itemlist)
else:
# data为空直接返回
return {"message": "not found"}
return post_list_json[0] if len(post_list_json) == 1 else post_list_json


def query_post(link, num, rule, ):
session = db_interface.db_init()
if link is None:
user = session.query(Friend).filter_by(
error=False).order_by(func.random()).first()
domain = parse.urlsplit(user.link).netloc
else:
domain = parse.urlsplit(link).netloc
user = session.query(Friend).filter(
Friend.link.like("%{:s}%".format(domain))).first()

posts = session.query(Post).filter(Post.link.like("%{:s}%".format(domain))).order_by(desc(rule)).limit(
num if num > 0 else None).all()
session.close()

data = []
for floor, post in enumerate(posts):
itemlist = {
"title": post.title,
"link": post.link,
"created": post.created,
"updated": post.updated,
"floor": floor + 1
}
data.append(itemlist)

if user:
api_json = {
"statistical_data": {
"name": user.name,
"link": user.link,
"avatar": user.avatar,
"article_num": len(posts)
},
"article_data": data
}
else:
# 如果user为空直接返回
return {"message": "not found"}

return api_json


def query_friend_status(days):
# 初始化数据库连接
session = db_interface.db_init()
# 查询
posts = session.query(Post).all()
friends = session.query(Friend).all()
name_2_link_map = {user.name: user.link for user in friends}
friend_status = {
"total_friend_num": len(name_2_link_map),
"total_lost_num": 0,
"total_not_lost_num": 0,
"lost_friends": {},
"not_lost_friends": {},
}
not_lost_friends = {}
for i in posts:
if not time_compare(i.updated, days):
# 未超过指定天数,未失联
if name_2_link_map.get(i.author):
not_lost_friends[i.author] = name_2_link_map.pop(i.author)
else:
pass
# 统计信息更新,失联友链更新
friend_status["total_not_lost_num"] = len(not_lost_friends)
friend_status["total_lost_num"] = friend_status["total_friend_num"] - \
friend_status["total_not_lost_num"]
friend_status["not_lost_friends"] = not_lost_friends
friend_status["lost_friends"] = name_2_link_map
return friend_status


def query_post_json(jsonlink, list, start, end, rule):
session = db_interface.db_init()

headers = {
"Cookie": "arccount62298=c; arccount62019=c",
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/87.0.4280.88 Safari/537.36 Edg/87.0.664.66"
}
jsonhtml = requests.get(jsonlink, headers=headers).text
linklist = set(json.loads(jsonhtml))
if not linklist:
# 如果为空直接返回
return {"message": "not found"}

posts = []
active_list = []
for link in linklist:
domain = parse.urlsplit(link).netloc
data = session.query(Post).filter(
Post.link.like("%{:s}%".format(domain))).all()
if data:
posts += data
active_list.append(link)

posts.sort(key=lambda x: getattr(x, rule), reverse=True)
post_num = len(posts)
last_update_time = max(x.createAt.strftime(
"%Y-%m-%d %H:%M:%S") for x in posts)

if end == -1:
end = min(post_num, 1000)
if start < 0 or start >= min(post_num, 1000):
return {"message": "start error"}
if end <= 0 or end > min(post_num, 1000):
return {"message": "end error"}
if rule != "created" and rule != "updated":
return {"message": "rule error, please use 'created'/'updated'"}

session.close()

friends_num = len(linklist)
active_num = len(active_list)
error_list = [link for link in linklist if link not in active_list]

post_data = []
for k in range(start, end):
item = {'floor': k + 1}
for elem in list:
item[elem] = getattr(posts[k], elem)
post_data.append(item)

data = {}
data['statistical_data'] = {
'friends_num': friends_num,
'linkinPubLibrary_num': active_num,
'linknoninPub_num': friends_num - active_num,
'article_num': post_num,
'last_updated_time': last_update_time,
'linknoninPub_list': error_list
}
data['article_data'] = post_data
return data


async def login_with_token_(token: str = Depends(dep.oauth2_scheme)):
# 获取或者创建(首次)secret_key
secert_key = await security.get_secret_key()
try:
payload = dep.decode_access_token(token, secert_key)
except JWTError:
raise format_response.CredentialsException

return payload


async def login_(password: str):
session = db_interface.db_init()
auth = session.query(Auth).all()
# 获取或者创建(首次)secret_key
secret_key = await security.get_secret_key()
if not auth:
# turn plain pwd to hashed pwd
password_hash = dep.create_password_hash(password)
# 未保存pwd,生成对应token并保存
data = {"password_hash": password_hash}
token = dep.encode_access_token(data, secret_key)
tb_obj = Auth(password=password_hash)
session.add(tb_obj)
session.commit()
session.close()
if tools.is_vercel():
await vercel_update_db()
elif len(auth) == 1:
# 保存了pwd,通过pwd验证
if dep.verify_password(password, auth[0].password):
# 更新token
data = {"password_hash": auth[0].password}
token = dep.encode_access_token(data, secret_key)
else:
# 401
return format_response.CredentialsException
else:
# 401
return format_response.CredentialsException

return format_response.standard_response(token=token)


async def db_reset_():
session = db_interface.db_init()
# 清除friend、post表
session.query(Friend).delete()
session.query(Post).delete()
session.commit()
session.close()
if tools.is_vercel():
await vercel_update_db()
return format_response.standard_response()

接口数据

  • error_num 记录无法爬取文章的友链数量
  • loss_num 实际无法访问的友联数量
  • friends_error 存放无法爬取文章的友链的信息
  • friends_loss 存放无法访问友链的信息

iShot_2024-08-08_17.04.10

部署注意

如果是之前部署过朋友圈的 然后进行了修改一定要先删除 data.db 然后重新部署

友联页展示

以本博客主题为例修改如下文件 layout/includes/page/flink.pug 可能我的版本较老 具体展示还是参考自己的界面 JS可以直接使用

#article-container
if theme.linkPageTop && theme.linkPageTop.enable
#flink-banners
.banner-top-box
.flink-banners-title
.banners-title-small 友情链接
.banners-title-big=theme.linkPageTop ? theme.linkPageTop.title : "与数百名博主无限进步"
.banner-button-group
if (theme.friends_vue.apiurl)
a.banner-button.secondary.no-text-decoration(onclick="friendChainRandomTransmission()")
i.anzhiyufont.anzhiyu-icon-paper-plane1
span.banner-button-text 随机访问
if theme.comment_barrage_config.enable && theme.comments.use == 'Twikoo'
a.banner-button.no-text-decoration(onclick="anzhiyu.addFriendLink()")
i.anzhiyufont.anzhiyu-icon-arrow-circle-right
span.banner-button-text 申请友链
#skills-tags-group-all
.tags-group-wrapper
- function getAvatarWithoutExclamationMark(url) {
- const index = url.indexOf('!');
- return index !== -1 ? url.substring(0, index) : url;
- }
each y in [1,2]
each i, index in site.data.link.slice(0, 15)
- const link_list = i.link_list.slice()
- const hundredSuffix = i.hundredSuffix ? i.hundredSuffix : ""
- const evenNum = link_list.filter((x, index) => index % 2 === 0);
- const oddNum = link_list.filter((x, index) => index % 2 === 1);
each item, index2 in link_list.slice(0, Math.min(evenNum.length, oddNum.length))
- const index = index2 * 2
if (index <= 15 && typeof evenNum[index] !== 'undefined' && typeof oddNum[index] !== 'undefined')
- let oddNumAvatar = getAvatarWithoutExclamationMark(oddNum[index].avatar);
- let evenNumAvatar = getAvatarWithoutExclamationMark(evenNum[index].avatar);
.tags-group-icon-pair
a.tags-group-icon.no-text-decoration(href=url_for(evenNum[index].link), title=evenNum[index].name)
img.no-lightbox(title=evenNum[index].name, src=url_for(evenNumAvatar + hundredSuffix) onerror=`this.onerror=null;this.src='` + url_for(theme.error_img.flink) + `'` alt=evenNum[index].name)
a.tags-group-icon.no-text-decoration(href=url_for(oddNum[index].link), title=oddNum[index].name)
img.no-lightbox(title=oddNum[index].name, src=url_for(oddNumAvatar + hundredSuffix) onerror=`this.onerror=null;this.src='` + url_for(theme.error_img.flink) + `'` alt=oddNum[index].name)
.title-h2-a
.title-h2-a-left
h2(style='padding-top:0;margin:.6rem 0 .6rem') 🎣 钓鱼
a.random-post-start.no-text-decoration(href='javascript:fetchRandomPost();')
i.anzhiyufont.anzhiyu-icon-arrow-rotate-right
.title-h2-a-right
a.random-post-all.no-text-decoration(href='/link/') 全部友链
#random-post

include ../anzhiyu/random-friends-post-js.pug
.flink
if site.data.link
each i in site.data.link
if i.class_name
h2!= i.class_name + "(" + i.link_list.length + ")"
if i.class_desc
.flink-desc!=i.class_desc
if i.flink_style === 'anzhiyu'
div(class=i.lost_contact ? 'anzhiyu-flink-list cf-friends-lost-contact' : 'anzhiyu-flink-list')
if i.link_list
each item in i.link_list
.flink-list-item
if item.recommend
span.site-card-tag 荐
a.cf-friends-link(href=url_for(item.link) title=item.name target="_blank")
if theme.lazyload.enable
img.cf-friends-avatar.no-lightbox(data-lazy-src=url_for(item.avatar) onerror=`this.onerror=null;this.src='` + url_for(theme.error_img.flink) + `'` alt=item.name )
else
img.cf-friends-avatar.no-lightbox(src=url_for(item.avatar) onerror=`this.onerror=null;this.src='` + url_for(theme.error_img.flink) + `'` alt=item.name )
.flink-item-info
if i.lost_contact
.flink-item-name.cf-friends-name-lost-contact= item.name
else
.flink-item-name.cf-friends-name= item.name
.flink-item-desc(title=item.descr)= item.descr
h2(id='lost-friends-title') 失联异常
.flink-desc 异常友联 可能存在问题
.loader-container
.loader
div(class='anzhiyu-flink-list cf-friends-lost-contact' id='lost-friends-container')
!= page.content
- const errimg = theme.error_img.flink;
script(defer).
function fetchLostFriends() {
const loader = document.querySelector('.loader-container');
loader.style.display = 'flex';
fetch('https://test.com/all')
.then(res => res.json())
.then(data => {
const lostFriendsContainer = document.getElementById('lost-friends-container');
const lostFriendsTitle = document.getElementById('lost-friends-title');
lostFriendsContainer.innerHTML = '';
lostFriendsTitle.textContent = '失联异常(' + data.statistical_data.loss_num + ')';
data.friends_loss.forEach(function (friend) {
const friendItem = document.createElement('div');
friendItem.className = 'flink-list-item';
var innerHTMLContent = '<a class="cf-friends-link" href="' + friend.link + '" title="' + friend.name + '" target="_blank">';
innerHTMLContent += '<img class="cf-friends-avatar no-lightbox nolazyload" src="https://test.com/img/err.webp" onerror="this.onerror=null;this.src=\'#{errimg}\'" alt="' + friend.name + '">';
innerHTMLContent += '<div class="flink-item-info">';
innerHTMLContent += '<div class="flink-item-name cf-friends-name-lost-contact">' + friend.name + '</div>';
innerHTMLContent += '</div>';
innerHTMLContent += '</a>';
friendItem.innerHTML = innerHTMLContent;
lostFriendsContainer.appendChild(friendItem);
});
loader.style.display = 'none';
})
.catch(function (error) {
console.error('Error fetching lost friends:', error);
loadingMessage.textContent = '数据加载失败';
});
}
fetchLostFriends();

只需要修改请求的URL 和 异常友联的头像文件 如下图示

当然了 如果你不想全部异常友联的头像都一样 就可以使用接口提供的数据,展示异常友联的事件头像

iShot_2024-08-08_17.13.38

添加加载效果

source/css/_page/flink.styl文件最后添加如下代码 也可以选择不添加加载动画 我是随便在css-loaders上找了一个

.loader-container
display: none
justify-content: center
align-items: center
position: relative
width: 100%
height: 100%
z-index: 1

.loader
width: 35px
aspect-ratio: 1
border-radius: 50%
background:
radial-gradient(farthest-side, #f03355 95%, #0000) 50% 1px/12px 12px no-repeat,
radial-gradient(farthest-side, #0000 calc(100% - 14px), #ccc 0)
animation: l9 2s infinite linear

@keyframes l9
to
transform: rotate(1turn)

效果

iShot_2024-08-08_17.22.11