<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[ElvishのBlog]]></title><description><![CDATA[尘隅]]></description><link>https://elvish.me</link><image><url>https://elvish.me/favicon.svg</url><title>ElvishのBlog</title><link>https://elvish.me</link></image><generator>Yohaku (https://github.com/Innei/Yohaku)</generator><lastBuildDate>Tue, 16 Jun 2026 14:45:56 GMT</lastBuildDate><atom:link href="https://elvish.me/feed" rel="self" type="application/rss+xml"/><pubDate>Tue, 16 Jun 2026 14:45:56 GMT</pubDate><language><![CDATA[zh-CN]]></language><item><title><![CDATA[观《超时空辉夜姬!》杂谈]]></title><description><![CDATA[<p>往原址览之：<a href="https://elvish.me/notes/2">https://elvish.me/notes/2</a></p>]]></description><link>https://elvish.me/notes/2</link><guid isPermaLink="true">https://elvish.me/notes/2</guid><dc:creator><![CDATA[Elvish]]></dc:creator><pubDate>Sun, 14 Jun 2026 10:50:57 GMT</pubDate></item><item><title><![CDATA[SSH登录Gotify通知脚本]]></title><description><![CDATA[<div><blockquote>此渲染由 Yohaku API 生成，或存排版之虞，最佳体验请往：<a href="https://elvish.me/posts/coding/ssh-login-gotify-script">https://elvish.me/posts/coding/ssh-login-gotify-script</a></blockquote><div><h1 id="">前言</h1><p>参考：</p><p><a href="https://community.hetzner.com/tutorials/ssh-notification-with-ntfy">https://community.hetzner.com/tutorials/ssh-notification-with-ntfy</a></p><h2 id="">功能</h2><ol start="1"><li><p>登录通知：当用户通过SSH登录时，向Gotify发送通知，包含</p><ul><li>登录用户</li><li>IP</li><li>主机名</li><li>地理位置</li><li>时间</li></ul></li><li><p>排除项：排除来自局域网和服务器自己公网ip的连接</p></li></ol><p>通知示例：</p><blockquote><p>用户 elvish 从 114.51.14.11 登录到 Ubuntu-24.04-server<br/>时间: 2026-01-01 10:00:00 CST<br/>🌍 位置: Shanghai, Shanghai, CN 🇨🇳</p></blockquote>
<h2 id="">说明</h2><ol start="1"><li>地理位置服务使用ipinfo.io的API通过ip地址反推，有速率限制</li><li>脚本会在每次SSH登录时执行，包括非交互式登录（如SCP、SFTP等）</li></ol><h1 id="">使用说明</h1><h3 id="1-">1. 创建脚本：</h3><pre class="language-bash lang-bash"><code class="language-bash lang-bash">sudo nano /usr/local/bin/ssh-login-notify.sh
</code></pre><p>粘贴以下内容：</p><pre class="language-shell lang-shell"><code class="language-shell lang-shell">
#!/bin/bash

USER_NAME=$PAM_USER
HOST_NAME=$(hostname)
TIME_NOW=$(TZ=Asia/Shanghai date &quot;+%Y-%m-%d %H:%M:%S %Z&quot;)
GOTIFY_URL=&quot;http://127.0.0.1:8118&quot;  # &lt;- 更改此处的地址和token
GOTIFY_TOKEN=&quot;&lt;GOTIFY_TOKEN&gt;&quot;

# 获取IP地址 - 修复SFTP连接显示问题
if [ -n &quot;$SSH_CONNECTION&quot; ]; then
  IP_ADDRESS=$(echo $SSH_CONNECTION | awk &#x27;{print $1}&#x27;)
else
  # 回退到who命令，但过滤掉无效的输出
  IP_ADDRESS=$(who | awk &#x27;{print $5}&#x27; | grep -Eo &#x27;([0-9]{1,3}\.){3}[0-9]{1,3}&#x27; | head -n 1)
fi

[ -z &quot;$IP_ADDRESS&quot; ] &amp;&amp; IP_ADDRESS=&quot;unknown&quot;

# 获取服务器的公网IP地址
get_server_public_ip() {
  # 尝试通过不同方式获取公网IP
  local public_ip=&quot;&quot;
  
  # 方法1: 使用dig查询DNS
  public_ip=$(dig +short myip.opendns.com @resolver1.opendns.com 2&gt;/dev/null)
  
  # 方法2: 使用curl查询外部服务
  if [ -z &quot;$public_ip&quot; ]; then
    public_ip=$(curl -s http://ifconfig.me 2&gt;/dev/null)
  fi
  
  # 方法3: 使用hostname命令
  if [ -z &quot;$public_ip&quot; ]; then
    public_ip=$(hostname -I | awk &#x27;{print $1}&#x27; 2&gt;/dev/null)
  fi
  
  echo &quot;$public_ip&quot;
}

# 检查是否为局域网、回环地址或服务器自身公网IP
is_local_or_self_ip() {
  local ip=$1
  # 回环地址
  if [[ $ip == &quot;127.0.0.1&quot; || $ip == &quot;::1&quot; || $ip == &quot;localhost&quot; ]]; then
    return 0
  fi
  
  # 私有IP地址范围
  if [[ $ip =~ ^10\. ]] || \
     [[ $ip =~ ^172\.(1[6-9]|2[0-9]|3[0-1])\. ]] || \
     [[ $ip =~ ^192\.168\. ]] || \
     [[ $ip =~ ^169\.254\. ]]; then
    return 0
  fi
  
  # 获取服务器公网IP
  local server_public_ip=$(get_server_public_ip)
  
  # 检查是否为服务器自身公网IP
  if [[ &quot;$ip&quot; == &quot;$server_public_ip&quot; ]]; then
    return 0
  fi
  
  # 不是本地IP或自身公网IP
  return 1
}

# 如果是局域网、回环地址或服务器自身公网IP，直接退出
if is_local_or_self_ip &quot;$IP_ADDRESS&quot;; then
  exit 0
fi

# 如果是会话关闭，退出
if [[ $PAM_TYPE == &#x27;close_session&#x27; ]]; then
  exit 0
fi

# 获取地理位置信息（可选）
GEO_INFO=&quot;&quot;
if [[ &quot;$IP_ADDRESS&quot; != &quot;unknown&quot; &amp;&amp; &quot;$IP_ADDRESS&quot; != &quot;127.0.0.1&quot; &amp;&amp; &quot;$IP_ADDRESS&quot; != ::1 ]]; then
  GEO_JSON=$(curl -s &quot;https://ipinfo.io/$IP_ADDRESS/json&quot;)
  CITY=$(echo &quot;$GEO_JSON&quot; | jq -r &#x27;.city&#x27;)
  REGION=$(echo &quot;$GEO_JSON&quot; | jq -r &#x27;.region&#x27;)
  COUNTRY=$(echo &quot;$GEO_JSON&quot; | jq -r &#x27;.country&#x27;)
  ORG=$(echo &quot;$GEO_JSON&quot; | jq -r &#x27;.org&#x27;)
  
  # 将国家代码转换为国旗表情符号的函数
  flag() {
    local code=&quot;${1^^}&quot;
    local first=$(( $(printf &quot;%d&quot; &quot;&#x27;${code:0:1}&quot;) - 65 + 0x1F1E6 ))
    local second=$(( $(printf &quot;%d&quot; &quot;&#x27;${code:1:1}&quot;) - 65 + 0x1F1E6 ))
    printf &#x27;%b&#x27; &quot;$(printf &#x27;\\U%08X\\U%08X&#x27; &quot;$first&quot; &quot;$second&quot;)&quot;
  }
  
  # 使用Unicode编码的表情符号
  EARTH_ICON=$(printf &#x27;\U1F30D&#x27;)
  GEO_INFO=&quot;$EARTH_ICON 位置: $CITY, $REGION, $COUNTRY $(flag $COUNTRY)&quot;
fi

# 构建消息内容
TITLE=&quot;SSH登录提醒 - $HOST_NAME&quot;
MESSAGE=&quot;用户 $USER_NAME 从 $IP_ADDRESS 登录到 $HOST_NAME
时间: $TIME_NOW
$GEO_INFO&quot;

# 发送到Gotify
if [ -n &quot;$SSH_CONNECTION&quot; ] || [ -n &quot;$SFTP_CONNECTION&quot; ]; then
  curl -s -o /dev/null \
    -X POST \
    -F &quot;title=${TITLE}&quot; \
    -F &quot;message=${MESSAGE}&quot; \
    -F &quot;priority=5&quot; \
    &quot;${GOTIFY_URL}/message?token=${GOTIFY_TOKEN}&quot;
fi


</code></pre>
<h3 id="2-">2. 设置执行权限：</h3><pre class="language-bash lang-bash"><code class="language-bash lang-bash">sudo chmod +x /usr/local/bin/ssh-login-notify.sh
</code></pre>
<h3 id="3-">3. 安装依赖（如果尚未安装）：</h3><pre class="language-bash lang-bash"><code class="language-bash lang-bash">sudo apt update &amp;&amp; sudo apt install jq dnsutils curl -y
</code></pre>
<h3 id="4-pam">4. 配置PAM：</h3><p>编辑SSH的PAM配置文件：</p><pre class="language-bash lang-bash"><code class="language-bash lang-bash">sudo nano /etc/pam.d/sshd
</code></pre>
<p>在文件末尾添加：</p><pre class=""><code class="">session optional pam_exec.so /usr/local/bin/ssh-login-notify.sh
</code></pre>
<h3 id="5-ssh">5. 重启SSH服务：</h3><pre class="language-bash lang-bash"><code class="language-bash lang-bash">sudo systemctl restart sshd
</code></pre></div><p style="text-align:right"><a href="https://elvish.me/posts/coding/ssh-login-gotify-script#comments">览毕，何不一言？</a></p></div>]]></description><link>https://elvish.me/posts/coding/ssh-login-gotify-script</link><guid isPermaLink="true">https://elvish.me/posts/coding/ssh-login-gotify-script</guid><dc:creator><![CDATA[Elvish]]></dc:creator><pubDate>Fri, 15 May 2026 12:38:22 GMT</pubDate></item><item><title><![CDATA[Porkbun付款后订单未完成解决方案]]></title><description><![CDATA[<link rel="preload" as="image" href="https://assets.elvish.me/images/2026/05/13/ozps42qt6uc214uy29.webp"/><link rel="preload" as="image" href="https://assets.elvish.me/images/2026/05/13/s9qa86vto96w4esz95.webp"/><link rel="preload" as="image" href="https://assets.elvish.me/images/2026/05/13/t0kolk3w33tpmjtkpf.webp"/><link rel="preload" as="image" href="https://assets.elvish.me/images/2026/05/13/9id89dcizmz3c6qsko.webp"/><div><blockquote>此渲染由 Yohaku API 生成，或存排版之虞，最佳体验请往：<a href="https://elvish.me/posts/experience/porkbun-order-not-completed-after-payment-solution">https://elvish.me/posts/experience/porkbun-order-not-completed-after-payment-solution</a></blockquote><div><h1 id="">问题状况</h1><p>在Porkbun使用支付宝付款并<strong>完成扣款</strong>后，域名显示未支付并仍在购物车里</p><p><img alt="账单显示 Paid $0.00" height="675" src="https://assets.elvish.me/images/2026/05/13/ozps42qt6uc214uy29.webp" width="1800"/></p><p>网上搜了一下，也有人遇到这样的问题，但是没有给出解决方案</p><p><a href="https://www.nodeseek.com/post-275039-1">https://www.nodeseek.com/post-275039-1</a></p><p><a href="https://www.reddit.com/r/PorkBun/comments/1i7k98w/ive_paid_for_my_transfer_but_its_still_in_the_cart/">https://www.reddit.com/r/PorkBun/comments/1i7k98w/ive_paid_for_my_transfer_but_its_still_in_the_cart/</a></p><p><a href="https://www.rua.ink/807.html">https://www.rua.ink/807.html</a></p><h1 id="">联系客服</h1><p>Porkbun 网站的在线客服简直是灾难</p><p>虽然网页右下角有个 HELP 按钮，但在手机端点不到，而且本质上也是个邮件表单。</p><p>于是直接给客服发邮件，详细列出了订单号、支付宝交易号等信息。</p><pre class="language-txt lang-txt"><code class="language-txt lang-txt">Subject: Payment Completed but Domain Still in Cart

Dear Porkbun Support Team,

I am writing to report an issue with my recent domain registration attempt. Today, I tried to register the domain example.com and completed the payment via Alipay. The payment was successfully deducted from my account, but the domain remains in my shopping cart and has not been registered.

Here are the details for your reference:

· Domain: example.com
· Porkbun Invoice/Order #: XXXXXXX
· Alipay Transaction ID: XXXXXXXXXXXXXXXX
· Merchant Order ID: src_XXXXXXXXXXXXXXXX
· Issue: The invoice in my Porkbun account shows a payment of $0, but the payment was successfully processed via Alipay.

I confirmed that the payment was deducted from my Alipay account, and I have also seen similar reports from other users on Reddit experiencing the same issue with payments via Alipay. I believe this is a technical error on your platform, and I would appreciate your assistance in resolving it promptly.

Could you please investigate this issue and complete the registration for the domain example.com as soon as possible? If additional verification is required from my end, please let me know.

Thank you for your attention to this matter. I look forward to your prompt response.

Best regards,
Elvish
</code></pre>
<p>美国客服效率还行，3 小时后回复了</p><p>但内容完全是标准化的“废话文学”：建议我关掉广告拦截器、VPN，或者换个浏览器重试。</p><p>这显然没用，因为问题在于钱扣了但系统没入账，而不是我的问题。</p><p>我猜测是<strong>Stripe与他们的系统的问题</strong>，商家后台应该有详细的交易记录。</p><p>于是我撰写了一封邮件，详细指出:</p><ol start="1"><li>请去 Stripe 后台 查询<strong>订单号</strong></li><li>附上了 支付宝<strong>交易详情截图</strong></li><li>附上了 <strong>电子回单</strong>（原版和翻译为英文的版本），证明我确实付了钱</li></ol><pre class="language-txt lang-txt"><code class="language-txt lang-txt">Dear NAME,

Thank you for your reply. I would like to clarify that the payment for the domain has already been successfully deducted from my Alipay account.

I notice that you use the Stripe platform to process Alipay payments. Could you please check the status of the specific order src_XXXXXXXXXXXXXXXX in your Stripe merchant dashboard?

I have taken a screenshot of the successful transaction from my Alipay account and also downloaded the official electronic transaction receipt. The original document (protected by a digital signature) is in Chinese. I have attached both the original Chinese receipt and an English translation to this email for your review.

If any other documentation is needed from my side to verify this payment, I am happy to provide it.

If there is an ongoing issue with this specific payment method on your end, I would appreciate it if you could initiate a refund for this transaction (src_XXXXXXXXXXXXXXXX) via your Stripe backend. Once the refund is confirmed, I am willing to complete the purchase for the domain using an alternative method, such as PayPal.

I look forward to your assistance in resolving this matter promptly.

Best regards,

</code></pre>
<p><img src="https://assets.elvish.me/images/2026/05/13/s9qa86vto96w4esz95.webp"/></p><p>一个小时过后，另外一名客服回复道:</p><blockquote><p>It looks like the payment was received, but the order failed. I&#x27;ve added the funds as account credit to your account.</p></blockquote>
<p>承认收到了钱，但订单挂了。</p><p>作为补偿，他们直接把钱充值到了我的 Porkbun 账户余额里。</p><h1 id="">重新下单</h1><p>登录账户一看，余额确实增加了。</p><p><img alt="账户余额已到账" src="https://assets.elvish.me/images/2026/05/13/t0kolk3w33tpmjtkpf.webp"/></p><p>接下来就简单了，再次下单，支付方式选择 Account Credit</p><p><img src="https://assets.elvish.me/images/2026/05/13/9id89dcizmz3c6qsko.webp"/></p><p>这下没出问题了</p><h1 id="">总结</h1><p>在国外一些平台消费，如果有条件，建议<strong>优先使用 PayPal 或 信用卡</strong></p><p>支付宝/Stripe 接口偶尔会出现回调失败的问题，处理起来比较费时费力</p><p>如果遇到类似情况，最好在附件中添加支付宝的交易详情截图和电子回单，尽量一封邮件证明清楚并解决问题</p><pre class="language-txt lang-txt"><code class="language-txt lang-txt">## Urgent: Payment Verified but Order Incomplete - Order #【你的订单号】 &amp; Stripe Charge src_【商家订单号】

Dear Porkbun Support Team,

I am writing to report a payment issue. The payment for my domain 【域名】 was successfully deducted from my Alipay account, but the domain remains in my cart and the invoice (#【你的订单号】) shows as unpaid.

I understand you use Stripe. Could you please check the status of the specific Stripe charge src_【商家订单号】 in your merchant dashboard?

For verification, I have attached:
1. A screenshot of the successful Alipay transaction.
2. The official Alipay e-receipt (original in Chinese + English translation).

If this payment cannot be applied to my order, please refund this charge (src_【商家订单号】) via your Stripe backend. I am then willing to repay immediately via PayPal or another method.

I look forward to your prompt resolution. Thank you.

Best regards,
【你的名字】

【在邮件里附上支付宝扣款截图和中英文电子流水回单】
</code></pre></div><p style="text-align:right"><a href="https://elvish.me/posts/experience/porkbun-order-not-completed-after-payment-solution#comments">览毕，何不一言？</a></p></div>]]></description><link>https://elvish.me/posts/experience/porkbun-order-not-completed-after-payment-solution</link><guid isPermaLink="true">https://elvish.me/posts/experience/porkbun-order-not-completed-after-payment-solution</guid><dc:creator><![CDATA[Elvish]]></dc:creator><pubDate>Wed, 13 May 2026 09:06:41 GMT</pubDate></item><item><title><![CDATA[制作自己的安卓开机动画]]></title><description><![CDATA[<div><blockquote>此渲染由 Yohaku API 生成，或存排版之虞，最佳体验请往：<a href="https://elvish.me/posts/tinkering/make-custom-boot-animation">https://elvish.me/posts/tinkering/make-custom-boot-animation</a></blockquote><div><h2 id="">引入</h2><p>前几天看见有人把《明日方舟：终末地》的LOGO改成了开机动画</p><p><a href="https://www.bilibili.com/video/BV1jPNKzkEvE">https://www.bilibili.com/video/BV1jPNKzkEvE</a></p><p>正好之前也自己改过一个COD里 <em>ATLAS</em> 的的开机动画，用了快一年了，正好换新换换口味，顺便复习一下制作的步骤</p><h2 id="">制作步骤</h2><h3 id="1">1.剪切视频</h3><p>原动画作者在 <a href="https://www.bilibili.com/video/BV1F5kPBUEFE">视频</a> 下面给出了网盘的视频源文件</p><p>下周后根据需要剪辑，剪掉两头多余的部分和中间同一画面停留过长的地方。</p><h3 id="2">2.视频切分为帧</h3><p>使用 <code>ffmpeg</code> 将视频切分为每帧一张图片，根据实际情况调整帧率</p><pre class="language-bash lang-bash"><code class="language-bash lang-bash">ffmpeg -i input.mp4 -vf &quot;fps=30&quot; %0d.png
</code></pre>
<h3 id="3">3.整理文件名</h3><p>对于这个只有一段的动画，将图片放到<code>part0</code>文件夹中</p><p>在图片目录打开PowerShell粘贴下方代码块中的内容进行规范命名。</p><pre class="language-powershell lang-powershell"><code class="language-powershell lang-powershell">Get-ChildItem -Filter *.png | ForEach-Object {
    if ($_.BaseName -match &#x27;^\d+$&#x27;) {
        $num = [int]$_.BaseName
        $newName = &quot;{0:D3}{1}&quot; -f $num, $_.Extension
        if ($_.Name -ne $newName) {
            Rename-Item -Path $_.FullName -NewName $newName
            Write-Host &quot;已重命名: $($_.Name) -&gt; $newName&quot; -ForegroundColor Green
        }
    }
}
</code></pre>
<h3 id="4">4.新建配置文件</h3><p>在<code>part0</code>同级目录新建<code>desc.txt</code></p><pre class="language-txt lang-txt"><code class="language-txt lang-txt">1080 1440 30
c 1 0 part0 000000
</code></pre>
<p>::: note
参数解析：</p><h4 id="">第一行：全局基础属性</h4><pre class="language-text lang-text"><code class="language-text lang-text">1080 1440 30

</code></pre><p>这行定义了动画的全局基础显示属性，格式为：<span>[WIDTH] [HEIGHT]</span> [FPS]</p><ul><li><strong>1080 (WIDTH)</strong>：动画的<strong>宽度</strong>，单位是像素。与图片分辨率一致即可。</li><li><strong>1440 (HEIGHT)</strong>：动画的<strong>高度</strong>，单位是像素。</li><li><strong>30 (FPS)</strong>：动画的<strong>帧率</strong>，代表每秒播放 30 张图片。</li></ul><h4 id="">第二行：动画段落播放控制</h4><p>格式均为：<span>[TYPE] [COUNT]</span> <span>[PAUSE] [PATH]</span> <span>[BACKGROUND_COLOR] [CLOCK]</span></p><pre class="language-text lang-text"><code class="language-text lang-text">c 1 0 part0 000000 -1

</code></pre><ul><li><strong>c (TYPE)</strong>：代表 &quot;complete&quot;（完整播放）。**即使系统在后台已经加载完毕，这一段也必须强制完整播完，不允许打断。另外可选： p：代表 &quot;play&quot;（可打断播放）。只要系统加载完毕准备好进入锁屏/桌面，无论这一段有没有播完，都会立刻被系统强制打断并结束开机画面。</li><li><strong>1 (COUNT)</strong>：播放次数。1 代表这一段动画（part0 文件夹里的图片）只播放 1 次。</li><li><strong>0 (PAUSE)</strong>：播放结束后的暂停时间，单位是<strong>帧数</strong>。停顿为 0（播完立刻进入下一阶段）</li><li><strong>part0 (PATH)</strong>：该段动画读取对应压缩包里的 part0 文件夹。</li><li><strong>#000000 (BACKGROUND)</strong>：这一段播放时的屏幕背景颜色，这里是纯黑色。</li><li><strong>-1 (CLOCK)</strong>：时钟显示的 Y 坐标（通常用于手表系统）。-1 代表隐藏/不显示时钟。</li></ul><p>:::</p><h3 id="5bootanimationzip">5.打包为bootanimation.zip</h3><p><code>part0</code>和<code>desc.txt</code>打包为一个<code>.zip</code>文件</p><p>压缩时选择<strong>压缩程度</strong>：<mark class="rounded-md"><span class="px-1"><strong>仅储存</strong></span></mark></p>
<p>最终结构：</p><pre class=""><code class="">bootanimation.zip
├── part0
│   ├── 001.png
│   ├── 002.png
│   ├── 003.png
│   └── ...
└── desc.txt

</code></pre>
<p>这是我制作的版本，可以 <a href="https://rua.ee/be">下载</a> 参考：</p>
<h2 id="">刷入手机</h2><p>由于我手机刷了第三方类原生的<em>Rising OS</em>系统，可以直接在设置中选择自定义的bootanimation.zip作为开机动画</p><p>对于一般已获取root的手机，可以构建一个简单的magisk模块刷入，覆盖系统原有的bootanimation即可</p></div><p style="text-align:right"><a href="https://elvish.me/posts/tinkering/make-custom-boot-animation#comments">览毕，何不一言？</a></p></div>]]></description><link>https://elvish.me/posts/tinkering/make-custom-boot-animation</link><guid isPermaLink="true">https://elvish.me/posts/tinkering/make-custom-boot-animation</guid><dc:creator><![CDATA[Elvish]]></dc:creator><pubDate>Sat, 02 May 2026 11:22:45 GMT</pubDate></item><item><title><![CDATA[华为路由器定时重新拨号刷新公网IP]]></title><description><![CDATA[<div><blockquote>此渲染由 Yohaku API 生成，或存排版之虞，最佳体验请往：<a href="https://elvish.me/posts/tinkering/huawei-router-scheduled-redial-refresh-ip">https://elvish.me/posts/tinkering/huawei-router-scheduled-redial-refresh-ip</a></blockquote><div><h2 id="">引入</h2><p>由于家宽公网 IPv4 每隔24~48小时，就会被运营商强制断线重连，导致CDN在回源时可能找不到源站。</p><p>尝试通过缩短A记录的TTL来缓解此现象，但是大陆的网络环境，各地运营商DNS层层缓存，这样没有作用</p><p>只能退而求其次，尽量控制<strong>让IP变动的时候在晚上</strong>，减少影响</p><p>本来可以通过软路由的定时任务来轻松解决的，但是我用于拨号的华为路由器没有这样的功能</p><p>因此，Vibe coding了一个脚本，适用于<mark class="rounded-md"><span class="px-1">华为AX3 Pro路由器</span></mark>的自动重新拨号</p><p>::: note
这个脚本仅在 AX3 Pro 进行过测试，其他型号的同种控制台应该也能使用。可能需要修改脚本中的div id
:::</p><h2 id="">分析</h2><p>本希望通过抓包登录和重启路由器的接口，但华为路由器采用了SCRAM (Salted Challenge Response Authentication Mechanism，加盐挑战响应认证机制) 或类似的安全握手协议，而不是直接发送明文密码。</p><p>因此每次登录时 <code>firstnonce</code>、<code>csrf_token</code> 和计算出来的 <code>clientproof</code> 都是动态变化的</p><p>如果希望通过脚本完成加盐挑战响应，需要解析来自路由器网页的js，比较麻烦</p><p>也尝试了通过抓包重启光猫也能达到目的（请求体是简单的明文），但是重启光猫时会导致座机也重启，晚上会发出声响</p><p>最后决定使用 <code>Playwright</code> 无头浏览器自动化点击来重新拨号</p><h3 id="1">1.安装依赖</h3><pre class="language-bash lang-bash"><code class="language-bash lang-bash">pip install playwright 
playwright install chromium 
playwright install-deps
</code></pre>
<h3 id="2">2.创建脚本</h3><p>在第 15 行填入登录密码</p><pre class="language-python lang-python"><code class="language-python lang-python">from playwright.sync_api import sync_playwright
import time

def reboot_router():
    with sync_playwright() as p:
        browser = p.chromium.launch(headless=True)
        page = browser.new_page()

        try:
            print(&quot;Opening router dashboard...&quot;)
            page.goto(&quot;http://192.168.3.1/&quot;)

            page.wait_for_load_state(&#x27;networkidle&#x27;)

            print(&quot;Logging in with password...&quot;)
            page.fill(&#x27;#userpassword_ctrl&#x27;, &#x27;&lt;PASSWORD&gt;&#x27;) # &lt;--把此处改为你的登录密码
            page.click(&#x27;#loginbtn&#x27;)

            time.sleep(3)

            print(&quot;Logged in，executing reconnecting action...&quot;)
            page.click(&#x27;#internet&#x27;)
            page.click(&#x27;#internet_reconnect&#x27;)
            print(&quot;Success.The network is about to reconnect...&quot;)
            time.sleep(2)

        except Exception as e:
            print(f&quot;An error occurred: {e}&quot;)
        finally:
            browser.close()

if __name__ == &quot;__main__&quot;:
    reboot_router()
</code></pre>
<h3 id="3">3.设置定时任务</h3><h5 id="cronjob"><strong>通过cronjob设置</strong></h5><pre class=""><code class="">crontab -e

# 添加脚本路径，设置每天凌晨3点执行
0 3 * * * /bin/bash -c &quot;cd /path/to/script &amp;&amp; source venv/bin/activate &amp;&amp; python main.py &gt;&gt; reboot.log 2&gt;&amp;1&quot;
</code></pre>
<h5 id="1panel"><strong>在1panel中设置</strong></h5><p>导航到 <code>计划任务-创建</code></p><ul><li>执行周期: 如<code>每天</code> <code>3 小时</code> <code>0 分钟</code></li><li>解释器: <code>python3</code></li><li>脚本内容: <code>路径选择</code>，并填入脚本路径</li></ul></div><p style="text-align:right"><a href="https://elvish.me/posts/tinkering/huawei-router-scheduled-redial-refresh-ip#comments">览毕，何不一言？</a></p></div>]]></description><link>https://elvish.me/posts/tinkering/huawei-router-scheduled-redial-refresh-ip</link><guid isPermaLink="true">https://elvish.me/posts/tinkering/huawei-router-scheduled-redial-refresh-ip</guid><dc:creator><![CDATA[Elvish]]></dc:creator><pubDate>Fri, 01 May 2026 14:57:05 GMT</pubDate></item><item><title><![CDATA[通过云函数和Webhook自动为文章生成Sink短链]]></title><description><![CDATA[<div><blockquote>此渲染由 Yohaku API 生成，或存排版之虞，最佳体验请往：<a href="https://elvish.me/posts/coding/auto-generate-sink-link-via-webhook-and-function">https://elvish.me/posts/coding/auto-generate-sink-link-via-webhook-and-function</a></blockquote><div><h1 id="">想法</h1><p>有时候觉得分享文章的时候默认的连接比较长</p><p>正好手上有短域名部署了Sink短链服务</p><p><a href="https://github.com/miantiao-me/sink">https://github.com/miantiao-me/sink</a></p><p>想到可以通过云函数功能配合Webhook事件来请求Sink的API生成短链，并用于文章分享</p><h1 id="mix-space-">Mix-space 后端部分</h1><h2 id="1-function">1.创建 Function</h2><p>在 <code>Mix-space后台-附加功能-配置与云函数</code>新增一个函数</p><ul><li>名称: <code>sink</code></li><li>引用: <code>shiro</code></li><li>类型: <code>Function</code></li><li>Secret 环境变量:</li></ul><table><thead><tr><th> 名称 </th><th> 示例值 </th><th> 说明 </th></tr></thead><tbody><tr><td> blog_url </td><td> <code>https://exmaple.com</code> </td><td> 博客地址，结尾不加<code>/</code> </td></tr><tr><td> sink_hostname </td><td> <code>sink.cool</code> </td><td> Sink实例地址，不加<code>https://</code>前缀，结尾不加<code>/</code> </td></tr><tr><td> sink_token </td><td> 123456　　 </td><td> Sink的<code>NUXT_SITE_TOKEN</code>，用于请求API时鉴权 </td></tr><tr><td> webhook_secret </td><td> 123456 </td><td> 自己填入一个密匙，用于Webhook请求鉴权，在后面添加Webhook时会用上 </td></tr></tbody></table><p>在编辑器中填入以下内容:</p><pre class="language-tsx lang-tsx"><code class="language-tsx lang-tsx">export default async function handler(ctx) {
  const { req, secret, broadcast } = ctx;
  const method = req.method;
  if (!secret.sink_hostname || !secret.sink_token || !secret.blog_url) {
    return { error: &quot;Missing configuration&quot; };
  }

  if (method === &#x27;GET&#x27;) {
    const { id, type } = req.query;
    if (!id || !type) {
      return &quot;ID and type are required&quot;;
    }
    const shortId = parseInt(id.slice(-8), 16).toString(36).slice(-4);
    const prefix = type.toLowerCase();
    if (prefix !== &#x27;p&#x27; &amp;&amp; prefix !== &#x27;n&#x27;) return &quot;Invalid type&quot;;
    const slug = `${prefix}${shortId}`;
    return `${secret.sink_hostname}/${slug}`;
  }

  if (!secret.webhook_secret || req.query.secret !== secret.webhook_secret) {
    return { error: &quot;Unauthorized webhook request&quot; };
  }

  const data = req.body;
  const event = req.headers[&#x27;x-webhook-event&#x27;];
  if (!event || !data) return { message: &quot;Empty payload&quot; };

  const docId = data._id || data.id;
  if (!docId) return { error: &quot;ID not found&quot; };

  const shortId = parseInt(docId.slice(-8), 16).toString(36).slice(-4);
  const getSlug = (ev) =&gt; {
    if (ev.startsWith(&#x27;POST_&#x27;)) return `p${shortId}`;
    if (ev.startsWith(&#x27;NOTE_&#x27;)) return `n${shortId}`;
    return null;
  };

  const targetSlug = getSlug(event);
  if (!targetSlug) return { message: &quot;Ignored event&quot; };

  let apiUrl = &quot;&quot;;
  let requestBody = {};
  let actionName = &quot;&quot;;

  if (event === &#x27;POST_CREATE&#x27; || event === &#x27;NOTE_CREATE&#x27;) {
    actionName = &quot;生成&quot;;
    apiUrl = `https://${secret.sink_hostname}/api/link/create`;
    let targetUrl = &quot;&quot;;
    if (event === &#x27;POST_CREATE&#x27;) {
      const categorySlug = data.category?.slug || &#x27;default&#x27;;
      targetUrl = `${secret.blog_url}/posts/${categorySlug}/${data.slug}`;
    } else {
      targetUrl = `${secret.blog_url}/notes/${data.nid}`;
    }
    requestBody = {
      url: targetUrl,
      slug: targetSlug,
      comment: `MixSpace - ${data.title || &#x27;Untitled&#x27;}`
    };
  } else if (event === &#x27;POST_DELETE&#x27; || event === &#x27;NOTE_DELETE&#x27;) {
    actionName = &quot;删除&quot;;
    apiUrl = `https://${secret.sink_hostname}/api/link/delete`;
    requestBody = { slug: targetSlug };
  } else {
    return { message: &quot;Unsupported action&quot; };
  }

  try {
    const response = await fetch(apiUrl, {
      method: &#x27;POST&#x27;,
      headers: {
        &#x27;Content-Type&#x27;: &#x27;application/json&#x27;,
        &#x27;Authorization&#x27;: `Bearer ${secret.sink_token}`
      },
      body: JSON.stringify(requestBody)
    });

    const responseText = await response.text();
    let result = {};
    if (responseText) {
      try {
        result = JSON.parse(responseText);
      } catch (e) {
        result = { raw: responseText };
      }
    }

    return { success: response.ok, slug: targetSlug, result };
  } catch (err) {
    return { error: &quot;Request failed&quot;, message: err.message };
  }
}

</code></pre><h4 id=""><strong>设计逻辑</strong></h4><p>最开始计划的逻辑是slug留空给sink生成，这样能保证短链尽可能短，并且不会重复。</p><p>但是根据 <ins><a href="https://sink.cool/_docs/swagger">Sink API 文档</a></ins> 可知，在请求删除一个短链时请求体参数需要slug，虽然可以先 LIST 找到 slug 再 DELETE，但是在短链变多后会变得很麻烦，LIST 只能分页查询。</p><p>因此需要能<mark class="rounded-md"><span class="px-1">根据文章的唯一 <code>_id</code> 反推 slug</span></mark>。</p><blockquote><p><code>_id</code> 实际上是 <strong>MongoDB 的 ObjectId</strong>。它是一个 24 位的十六进制字符串。<strong>前 8 位</strong>：是<strong>时间戳</strong>（精确到秒）。由文章创建时间决定。<strong>中间 10 位</strong>：是机器标识码和进程 ID（通常是随机生成的）。<strong>最后 6 位</strong>：是一个<strong>自增计数器</strong>（每次插入新数据时加 1）。</p></blockquote>
<p>因此，短链生成的逻辑是：</p><h3 id="1-4-p--n---postnote-slug-pabva--n1afc">1. 在计算出的4位前面加上 <code>p</code> 或 <code>n</code> 用于标识  <code>post</code>或<code>note</code> ，最终得到的slug形如 <code>pabva</code> 和 <code>n1afc</code></h3><blockquote class="markdown-alert-note"><header>NOTE</header><h5 id="id"><strong>为什么不直接取id的后四位？</strong></h5><br/><p>id是一串16进制字符，那么它的命名空间只有0-9,a-f，这导致命名空间大幅减少，使得两篇文章slug重复的可能性增大，并且也不够美观。</p><br/><h5 id="slug"><strong>当前方案slug重复的可能性有多大？</strong></h5><br/><p>理论上存在，但对于个人博客来说，实际重复风险极低，基本可以忽略不计。
<br/>数学原理: Base36 包含 36 个字符（0-9，a-z）。截取最后 4 位，意味着共有 36<sup>4</sup> = 1,679,616（约 167 万）种唯一的组合。</p><p>底层机制：MongoDB ID 的尾部是一个严格自增的计数器。当你截取转换后的后 4 位时，实际上是在做类似取模的循环。这意味着，除非博客文章和日记总数达到 167 万篇，否则在单台服务器上几乎不可能发生“转了一圈又撞上”的情况。</p></blockquote>
<h3 id="2webhook">2.添加Webhook</h3><p>在 <code>附加功能-Webhooks-添加Webhook</code></p><ul><li>Payload URL:  <code>https://example.com/api/v2/fn/shiro/sink?secret=&lt;YOUR_WEBHOOK_TOKEN&gt;</code></li><li>Secret 留空 <del>(因为我似乎发现填写了此值也会显示&quot;未配置Secret&quot;，故弃用，直接填在URL后面)</del></li><li>触发事件：<code>POST_CREEAT</code> <code>POST_DELETE</code> <code>NOTE_CREAT</code> <code>NOTE_DELETE</code></li><li>触发范围：系统事件</li><li>启用状态：启用</li></ul><blockquote class="markdown-alert-note"><header>NOTE</header><p> 点击  <code>发送测试</code> 会提示失败，因为测试的payload与实际payload不同，实际使用时不会报错。</p></blockquote>
<p>新建一篇文章测试，点击右上角发布，查看webhook日志，在Response中有以下字段，代表创建短链成功：</p><pre class="language-json lang-json"><code class="language-json lang-json">      &quot;shortLink&quot;: &quot;https://rua.ee/pkrw6&quot;
</code></pre>
<h3 id="3">3.前端获取短链</h3><p>云函数中定义了一个API，在浏览器中输入地址可以获取已知id文章或手记的短链</p><p><code>GET https://example.com/api/v2/fn/shiro/sink?id=&lt;id&gt;&amp;type=p</code></p><ul><li><code>id</code> 为文章或手记的唯一<code>id</code></li><li><code>type</code> 为类型， <code>p</code> 代表 <code>posts</code>，<code>n</code> 代表 <code>notes</code></li></ul><hr/><h1 id="">前端部分</h1><p>由于更改的文件过多，本想通过提供github commit 链接做参考</p><p>但是我的仓库为了尊重原作者的工作也是 private，故给出以下详细提示词，可以使用 AI Agent 辅助完成修改</p><p>这个份提示词适用于<strong>单域名</strong>部署的Yohaku站点，如果你采用的是<strong>双域名</strong>的部署方式，可能需要<mark class="rounded-md"><span class="px-1">修改 第 5 行 的描述</span></mark></p><blockquote><p>这是Codex GPT 5.5-Medium 给出的plan，执行修改后一次成功，您可以复制给AI Agent尝试</p></blockquote>
<pre class="language-md lang-md"><code class="language-md lang-md"># 通过短链分享文章/手记

## Summary
- 将 posts 和 notes 的桌面侧栏、移动底栏分享入口统一改为“通过短链分享”。
- 点击后请求 `/api/v2/fn/shiro/sink?id=&lt;id&gt;&amp;type=&lt;type&gt;`，复制返回的短链到剪贴板，并用项目 toast 显示“已复制短链。”
- 移除原来的系统分享 API、分享弹窗降级逻辑和不再使用的 `ShareModal`。

## Key Changes
- 在 `PostActionAside.tsx` 中替换 `ShareMark` 和 `ShareButton`：
  - 从当前 post 数据读取 `id`。
  - 请求 `type=p`。
  - 删除 `navigator.share`、`useModalStack`、`ShareModal`、`routeBuilder`、`Routes`、`urlBuilder` 相关依赖。
- 在 `NoteActionAside.tsx` 中替换 `ShareTab` 和 `WashiShareButton`：
  - 从当前 note 数据读取 `id`。
  - 请求 `type=n`。
  - 删除 `navigator.share`、`useModalStack`、`ShareModal`、`buildNotePath`、`urlBuilder` 相关分享依赖。
- 新增一个小型共享 helper，供 post/note 复用：
  - 调用 `$fetch(&#x27;/api/v2/fn/shiro/sink&#x27;, { query: { id, type } })`。
  - 接受纯字符串响应；也兼容常见 JSON 响应字段如 `url`、`link`、`shortUrl`、`shortLink`、`data`。
  - 成功后 `navigator.clipboard.writeText(shortLink)`，再 `toast.success(t(&#x27;copy_short_link_success&#x27;))`。
  - 获取或复制失败时 `toast.error(t(&#x27;copy_short_link_failed&#x27;))`。
- 删除 `apps/web/src/components/modules/shared/ShareModal.tsx`，前提是替换后全局无引用。

## i18n
- 更新所有 locale 的 `common.json`：
  - `aria_share_post`: 改为“通过短链分享”语义。
  - `aria_share_note`: 改为“通过短链分享”语义。
  - 新增并使用 `copy_short_link_success`，中文值为 `已复制短链。`
  - 新增并使用 `copy_short_link_failed`，用于接口或剪贴板失败提示。
- 覆盖 `zh`、`zh-TW`、`en`、`ja`、`ko`，保持 message key 同步。

## Test Plan
- 运行 `pnpm --dir apps/web vitest run src/messages/message-usage.test.ts`，确认新增 i18n key 被引用且没有未使用 key。
- 运行 `pnpm --dir apps/web lint` 或至少对相关文件执行类型/ lint 检查。
- 手动验证：
  - post 页面桌面侧栏点击分享，请求带 `type=p` 和 post `id`，剪贴板得到短链，toast 显示成功文案。
  - post 页面移动底栏同样生效。
  - note 页面桌面侧栏/嵌入侧栏点击分享，请求带 `type=n` 和 note `id`。
  - note 页面移动底栏同样生效。
  - API 失败或剪贴板失败时显示失败 toast，不再弹出系统分享面板或 ShareModal。

## Assumptions
- 短链接口是同源相对路径，可从浏览器直接请求。
- 接口成功响应中至少会返回短链字符串，或返回包含短链字段的 JSON；实现会兼容这两类形态。
- “posts 和 notes 侧栏”包含当前文件里的桌面侧栏入口，也包含对应移动底栏分享按钮，因为原分享逻辑在两端各有一份。

</code></pre></div><p style="text-align:right"><a href="https://elvish.me/posts/coding/auto-generate-sink-link-via-webhook-and-function#comments">览毕，何不一言？</a></p></div>]]></description><link>https://elvish.me/posts/coding/auto-generate-sink-link-via-webhook-and-function</link><guid isPermaLink="true">https://elvish.me/posts/coding/auto-generate-sink-link-via-webhook-and-function</guid><dc:creator><![CDATA[Elvish]]></dc:creator><pubDate>Mon, 27 Apr 2026 06:18:44 GMT</pubDate></item><item><title><![CDATA[shiro status 函数优化与多端agent上报]]></title><description><![CDATA[<div><blockquote>此渲染由 Yohaku API 生成，或存排版之虞，最佳体验请往：<a href="https://elvish.me/posts/archive/shiro-status-function-and-crossplatform-agent">https://elvish.me/posts/archive/shiro-status-function-and-crossplatform-agent</a></blockquote><div><p>::: warning</p><p>此方法已弃用，因为这个好像才是正解</p><p><a href="https://github.com/mx-space/snippets/blob/main/shiro/functions/status.ts">https://github.com/mx-space/snippets/blob/main/shiro/functions/status.ts</a></p><p>:::</p>
<p>最近在折腾Mix-space的云函数功能，在配置status函数的过程中遇到了一些问题，分享一下解决过程以及自己的一些新想法</p><h2 id="status">优化原作者提供的status函数</h2><p>在配置Yohaku的status云函数时注意到上传状态报错<span style="color:#CA2A30">204</span></p><p><a href="https://github.com/mx-space/snippets/blob/main/shiro/functions/status.ts">https://github.com/mx-space/snippets/blob/main/shiro/functions/status.ts</a></p><p>注释掉第 61 行可正常使用</p><pre class="language-ts lang-ts"><code class="language-ts lang-ts">
  const status = {
    emoji,
    icon,
    desc,
    ttl,
    untilAt: Date.now() + ttl * 1000,
  } as Status
  ctx.storage.cache.set(cacheKey, JSON.stringify(status), ttl)
  // ctx.status(204)

  ctx.broadcast(&#x27;shiro#status&#x27;, status)
}

</code></pre>
<h2 id="agent">简易的多平台Agent客户端</h2><h3 id="1android-">1.Android 客户端</h3><p>由于目前没用安卓客户端，所以根据 <ins><a href="https://github.com/Monika-Dream/live-dashboard/tree/android-source">live-dashboard</a></ins> 魔改了一个基于无障碍权限获取前台应用名称的安卓客户端：</p><p><a href="https://github.com/Elvish064/Process-Reporter">https://github.com/Elvish064/Process-Reporter</a></p><p>可在 <ins><a href="https://github.com/Elvish064/Process-Reporter/releases">Release</a></ins> 中下载编译好的版本</p><h3 id="2windows-">2.Windows 客户端</h3><p>顺手也改了一个win版本，可自行通过<code>pyinstaller</code>打包为exe可执行文件</p><pre class="language-python lang-python"><code class="language-python lang-python">import ctypes
import ctypes.wintypes
import ipaddress
import json
import logging
import logging.handlers
import os
import socket
import subprocess
import sys
import threading
import time
import urllib.parse
from pathlib import Path

import psutil
import requests

if getattr(sys, &quot;frozen&quot;, False):
    base_dir = Path(sys.executable).parent
else:
    base_dir = Path(__file__).parent

# ---------------------------------------------------------------------------
# Logging - console always; file handler toggleable (2-day rotation)
# ---------------------------------------------------------------------------
LOG_FILE = base_dir / &quot;agent.log&quot;
_file_handler: logging.Handler | None = None

logging.basicConfig(
    level=logging.INFO,
    format=&quot;%(asctime)s [%(levelname)s] %(message)s&quot;,
    handlers=[logging.StreamHandler()],
)
log = logging.getLogger(&quot;agent&quot;)


def set_file_logging(enabled: bool) -&gt; None:
    &quot;&quot;&quot;Toggle file logging with 2-day rotation.&quot;&quot;&quot;
    global _file_handler
    if enabled and _file_handler is None:
        _file_handler = logging.handlers.TimedRotatingFileHandler(
            LOG_FILE, when=&quot;midnight&quot;, backupCount=1, encoding=&quot;utf-8&quot;,
        )
        _file_handler.setFormatter(
            logging.Formatter(&quot;%(asctime)s [%(levelname)s] %(message)s&quot;)
        )
        logging.getLogger().addHandler(_file_handler)
    elif not enabled and _file_handler is not None:
        logging.getLogger().removeHandler(_file_handler)
        _file_handler.close()
        _file_handler = None


# ---------------------------------------------------------------------------
# Win32 API bindings
# ---------------------------------------------------------------------------
user32 = ctypes.windll.user32  # type: ignore[attr-defined]

GetForegroundWindow = user32.GetForegroundWindow
GetForegroundWindow.restype = ctypes.wintypes.HWND

GetWindowTextW = user32.GetWindowTextW
GetWindowTextW.argtypes = [ctypes.wintypes.HWND, ctypes.wintypes.LPWSTR, ctypes.c_int]
GetWindowTextW.restype = ctypes.c_int

GetWindowTextLengthW = user32.GetWindowTextLengthW
GetWindowTextLengthW.argtypes = [ctypes.wintypes.HWND]
GetWindowTextLengthW.restype = ctypes.c_int

GetWindowThreadProcessId = user32.GetWindowThreadProcessId
GetWindowThreadProcessId.argtypes = [ctypes.wintypes.HWND, ctypes.POINTER(ctypes.wintypes.DWORD)]
GetWindowThreadProcessId.restype = ctypes.wintypes.DWORD


def get_foreground_info() -&gt; tuple[str, str] | None:
    &quot;&quot;&quot;Return (process_name, window_title) of the current foreground window.&quot;&quot;&quot;
    hwnd = GetForegroundWindow()
    if not hwnd:
        return None
    length = GetWindowTextLengthW(hwnd)
    title = &quot;&quot;
    if length &gt; 0:
        buf = ctypes.create_unicode_buffer(length + 1)
        GetWindowTextW(hwnd, buf, length + 1)
        title = buf.value.strip()
    pid = ctypes.wintypes.DWORD()
    GetWindowThreadProcessId(hwnd, ctypes.byref(pid))
    try:
        proc = psutil.Process(pid.value)
        proc_name = proc.name()
    except (psutil.NoSuchProcess, psutil.AccessDenied):
        proc_name = &quot;unknown&quot;
    return proc_name, title

def format_report_desc(app_id: str, window_title: str) -&gt; str:
    &quot;&quot;&quot;Return the reported desc: title first, then process name without .exe.&quot;&quot;&quot;
    title = (window_title or &quot;&quot;).strip()
    if title:
        return title[:256]
    app = (app_id or &quot;&quot;).strip() or &quot;unknown&quot;
    if app.lower().endswith(&quot;.exe&quot;):
        app = app[:-4]
    return app or &quot;unknown&quot;


# ---------------------------------------------------------------------------
# Config - stored next to the exe for easy cleanup
# ---------------------------------------------------------------------------
CONFIG_PATH = base_dir / &quot;config.json&quot;

_DEFAULT_CFG = {
    &quot;server_url&quot;: &quot;&quot;,
    &quot;token&quot;: &quot;&quot;,
    &quot;emoji&quot;: &quot;&quot;,
    &quot;heartbeat_seconds&quot;: 60,
    &quot;sensitive_words_regex&quot;: &quot;&quot;,
    &quot;enable_log&quot;: False,
}

MONITOR_POLL_SECONDS = 5


def load_config() -&gt; dict:
    &quot;&quot;&quot;Load config.json, return config dict (may be empty on error).&quot;&quot;&quot;
    try:
        with open(CONFIG_PATH, &quot;r&quot;, encoding=&quot;utf-8&quot;) as f:
            cfg = json.load(f)
    except FileNotFoundError:
        return dict(_DEFAULT_CFG)
    except (PermissionError, json.JSONDecodeError) as e:
        log.error(&quot;config.json: %s&quot;, e)
        return dict(_DEFAULT_CFG)

    if not isinstance(cfg, dict):
        return dict(_DEFAULT_CFG)

    normalized = dict(_DEFAULT_CFG)

    for key in (&quot;server_url&quot;, &quot;token&quot;, &quot;emoji&quot;, &quot;sensitive_words_regex&quot;):
        value = cfg.get(key, _DEFAULT_CFG[key])
        normalized[key] = value.strip() if isinstance(value, str) else _DEFAULT_CFG[key]

    enable_log = cfg.get(&quot;enable_log&quot;, _DEFAULT_CFG[&quot;enable_log&quot;])
    normalized[&quot;enable_log&quot;] = enable_log if isinstance(enable_log, bool) else _DEFAULT_CFG[&quot;enable_log&quot;]

    val = cfg.get(&quot;heartbeat_seconds&quot;, _DEFAULT_CFG[&quot;heartbeat_seconds&quot;])
    if not isinstance(val, (int, float)) or val &lt; 10 or val &gt; 600:
        val = _DEFAULT_CFG[&quot;heartbeat_seconds&quot;]
    normalized[&quot;heartbeat_seconds&quot;] = int(val)

    return normalized


def save_config(cfg: dict) -&gt; bool:
    &quot;&quot;&quot;Save config to config.json atomically with restricted permissions.&quot;&quot;&quot;
    import tempfile
    try:
        data = json.dumps(cfg, indent=2, ensure_ascii=False).encode(&quot;utf-8&quot;)
        fd = tempfile.NamedTemporaryFile(
            dir=CONFIG_PATH.parent, prefix=&quot;.config_&quot;, suffix=&quot;.tmp&quot;,
            delete=False,
        )
        tmp_path = Path(fd.name)
        try:
            fd.write(data)
            fd.flush()
            os.fsync(fd.fileno())
            fd.close()
            os.chmod(tmp_path, 0o600)
            tmp_path.replace(CONFIG_PATH)
        except BaseException:
            fd.close()
            tmp_path.unlink(missing_ok=True)
            raise
        return True
    except Exception as e:
        log.error(&quot;Config save failed: %s&quot;, e)
        return False


def validate_config(cfg: dict) -&gt; str | None:
    &quot;&quot;&quot;Validate config. Return error message or None if valid.&quot;&quot;&quot;
    url = cfg.get(&quot;server_url&quot;, &quot;&quot;).strip()
    token = cfg.get(&quot;token&quot;, &quot;&quot;).strip()
    sensitive_regex = cfg.get(&quot;sensitive_words_regex&quot;, &quot;&quot;).strip()
    
    if not url:
        return &quot;上报接口 URL 不能为空&quot;
    if not token or token == &quot;YOUR_TOKEN_HERE&quot;:
        return &quot;Token 不能为空&quot;
        
    if sensitive_regex:
        import re
        try:
            re.compile(sensitive_regex)
        except re.error:
            return &quot;标题敏感词排除正则表达式无效&quot;

    parsed = urllib.parse.urlparse(url)
    scheme = parsed.scheme.lower()
    hostname = parsed.hostname
    if scheme not in (&quot;http&quot;, &quot;https&quot;):
        return &quot;上报接口 URL 必须使用 http:// 或 https://&quot;
    if not hostname:
        return &quot;上报接口 URL 无效&quot;

    if scheme == &quot;http&quot;:
        try:
            addrinfos = socket.getaddrinfo(hostname, None, socket.AF_UNSPEC, socket.SOCK_STREAM)
        except socket.gaierror:
            return f&quot;无法解析域名: {hostname}&quot;
        for info in addrinfos:
            ip = ipaddress.ip_address(info[4][0])
            if ip.is_global:
                return &quot;HTTP 仅允许内网地址，公网请使用 HTTPS&quot;

    return None


# ---------------------------------------------------------------------------
# Windows autostart
# ---------------------------------------------------------------------------
AUTOSTART_NAME = &quot;LiveDashboardAgent&quot;
AUTOSTART_RUN_KEY = r&quot;Software\Microsoft\Windows\CurrentVersion\Run&quot;


def _get_autostart_command() -&gt; str:
    &quot;&quot;&quot;Return the command line used for login autostart.&quot;&quot;&quot;
    if getattr(sys, &quot;frozen&quot;, False):
        return subprocess.list2cmdline([str(Path(sys.executable).resolve())])
    return subprocess.list2cmdline([sys.executable, str(Path(__file__).resolve())])


def _has_registry_autostart() -&gt; bool:
    &quot;&quot;&quot;Return whether the current user has a Run-key startup entry.&quot;&quot;&quot;
    try:
        import winreg
        with winreg.OpenKey(winreg.HKEY_CURRENT_USER, AUTOSTART_RUN_KEY) as key:
            value, _ = winreg.QueryValueEx(key, AUTOSTART_NAME)
    except FileNotFoundError:
        return False
    except OSError as e:
        log.warning(&quot;Autostart registry query failed: %s&quot;, e)
        return False
    return isinstance(value, str) and bool(value.strip())


def _set_registry_autostart(enabled: bool) -&gt; bool:
    &quot;&quot;&quot;Enable/disable login autostart through the current-user Run key.&quot;&quot;&quot;
    try:
        import winreg
        with winreg.CreateKey(winreg.HKEY_CURRENT_USER, AUTOSTART_RUN_KEY) as key:
            if enabled:
                winreg.SetValueEx(
                    key, AUTOSTART_NAME, 0, winreg.REG_SZ, _get_autostart_command()
                )
            else:
                try:
                    winreg.DeleteValue(key, AUTOSTART_NAME)
                except FileNotFoundError:
                    pass
        return True
    except OSError as e:
        log.error(&quot;Autostart registry update failed: %s&quot;, e)
        return False


def _has_legacy_startup_task() -&gt; bool:
    &quot;&quot;&quot;Return whether the legacy scheduled task based autostart exists.&quot;&quot;&quot;
    try:
        result = subprocess.run(
            [&quot;schtasks&quot;, &quot;/query&quot;, &quot;/tn&quot;, AUTOSTART_NAME],
            capture_output=True,
            text=True,
            check=False,
            timeout=5,
        )
    except (OSError, subprocess.SubprocessError) as e:
        log.debug(&quot;Autostart task query failed: %s&quot;, e)
        return False
    return result.returncode == 0


def _remove_legacy_startup_task() -&gt; bool:
    &quot;&quot;&quot;Remove the legacy scheduled task if it exists.&quot;&quot;&quot;
    if not _has_legacy_startup_task():
        return True
    try:
        result = subprocess.run(
            [&quot;schtasks&quot;, &quot;/delete&quot;, &quot;/tn&quot;, AUTOSTART_NAME, &quot;/f&quot;],
            capture_output=True,
            text=True,
            check=False,
            timeout=10,
        )
    except (OSError, subprocess.SubprocessError) as e:
        log.warning(&quot;Legacy startup task removal failed: %s&quot;, e)
        return False
    if result.returncode == 0:
        return True
    output = (result.stderr or result.stdout).strip()
    if output:
        log.warning(&quot;Legacy startup task removal failed: %s&quot;, output)
    return False


def is_autostart_enabled() -&gt; bool:
    &quot;&quot;&quot;Return whether the agent is configured to launch at Windows logon.&quot;&quot;&quot;
    return _has_registry_autostart() or _has_legacy_startup_task()


def show_message(title: str, message: str, error: bool = False) -&gt; None:
    &quot;&quot;&quot;Show a best-effort native message box for user-facing actions.&quot;&quot;&quot;
    try:
        flags = 0x10 if error else 0x40
        ctypes.windll.user32.MessageBoxW(None, message, title, flags)  # type: ignore[attr-defined]
    except Exception:
        log.info(&quot;%s: %s&quot;, title, message)


# ---------------------------------------------------------------------------
# Settings Dialog
# ---------------------------------------------------------------------------
def show_settings_dialog(current_config: dict | None = None) -&gt; dict | None:
    &quot;&quot;&quot;Show tkinter settings dialog. Returns new config or None if cancelled.&quot;&quot;&quot;
    try:
        import tkinter as tk
        from tkinter import ttk, messagebox
    except ImportError:
        log.error(&quot;tkinter 不可用，请手动编辑 %s&quot;, CONFIG_PATH)
        return None

    cfg = current_config or dict(_DEFAULT_CFG)
    result: list[dict | None] = [None]

    root = tk.Tk()
    root.title(&quot;Live Dashboard - 设置&quot;)
    root.resizable(False, False)

    frame = ttk.Frame(root, padding=20)
    frame.pack(fill=&quot;both&quot;, expand=True)

    ttk.Label(frame, text=&quot;上报接口 URL:&quot;).grid(row=0, column=0, sticky=&quot;w&quot;, pady=6)
    url_var = tk.StringVar(value=cfg.get(&quot;server_url&quot;, &quot;&quot;))
    ttk.Entry(frame, textvariable=url_var, width=45).grid(row=0, column=1, pady=6, padx=(8, 0))

    ttk.Label(frame, text=&quot;Token:&quot;).grid(row=1, column=0, sticky=&quot;w&quot;, pady=6)
    token_var = tk.StringVar(value=cfg.get(&quot;token&quot;, &quot;&quot;))
    ttk.Entry(frame, textvariable=token_var, width=45, show=&quot;*&quot;).grid(row=1, column=1, pady=6, padx=(8, 0))

    ttk.Label(frame, text=&quot;Emoji:&quot;).grid(row=2, column=0, sticky=&quot;w&quot;, pady=6)
    emoji_var = tk.StringVar(value=cfg.get(&quot;emoji&quot;, &quot;&quot;))
    ttk.Entry(frame, textvariable=emoji_var, width=45).grid(row=2, column=1, pady=6, padx=(8, 0))

    ttk.Label(frame, text=&quot;心跳间隔 (秒):&quot;).grid(row=3, column=0, sticky=&quot;w&quot;, pady=6)
    heartbeat_var = tk.IntVar(value=cfg.get(&quot;heartbeat_seconds&quot;, 60))
    ttk.Spinbox(frame, textvariable=heartbeat_var, from_=10, to=600, width=10).grid(row=3, column=1, sticky=&quot;w&quot;, pady=6, padx=(8, 0))

    ttk.Label(frame, text=&quot;标题敏感词排除 (正则):&quot;).grid(row=4, column=0, sticky=&quot;w&quot;, pady=6)
    sensitive_var = tk.StringVar(value=cfg.get(&quot;sensitive_words_regex&quot;, &quot;&quot;))
    ttk.Entry(frame, textvariable=sensitive_var, width=45).grid(row=4, column=1, pady=6, padx=(8, 0))

    log_var = tk.BooleanVar(value=cfg.get(&quot;enable_log&quot;, False))
    ttk.Checkbutton(frame, text=&quot;开启日志文件 (保留 2 天)&quot;, variable=log_var).grid(
        row=5, column=0, columnspan=2, sticky=&quot;w&quot;, pady=6
    )

    def on_save():
        new_cfg = {
            &quot;server_url&quot;: url_var.get().strip(),
            &quot;token&quot;: token_var.get().strip(),
            &quot;emoji&quot;: emoji_var.get().strip(),
            &quot;heartbeat_seconds&quot;: heartbeat_var.get(),
            &quot;sensitive_words_regex&quot;: sensitive_var.get().strip(),
            &quot;enable_log&quot;: log_var.get(),
        }
        err = validate_config(new_cfg)
        if err:
            messagebox.showerror(&quot;配置错误&quot;, err, parent=root)
            return
        if save_config(new_cfg):
            result[0] = new_cfg
            root.destroy()
        else:
            messagebox.showerror(&quot;保存失败&quot;, &quot;无法写入 config.json&quot;, parent=root)

    btn_frame = ttk.Frame(frame)
    btn_frame.grid(row=6, column=0, columnspan=2, pady=16)
    ttk.Button(btn_frame, text=&quot;保存&quot;, command=on_save).pack(side=&quot;left&quot;, padx=12)
    ttk.Button(btn_frame, text=&quot;取消&quot;, command=root.destroy).pack(side=&quot;left&quot;, padx=12)

    # Center on screen
    root.update_idletasks()
    w, h = root.winfo_reqwidth(), root.winfo_reqheight()
    x = (root.winfo_screenwidth() - w) // 2
    y = (root.winfo_screenheight() - h) // 2
    root.geometry(f&quot;+{x}+{y}&quot;)
    root.lift()
    root.focus_force()

    root.mainloop()
    return result[0]


# ---------------------------------------------------------------------------
# Reporter
# ---------------------------------------------------------------------------
class Reporter:
    &quot;&quot;&quot;Handles sending reports to the backend with exponential backoff.&quot;&quot;&quot;

    MAX_BACKOFF = 60
    PAUSE_AFTER_FAILURES = 5
    PAUSE_DURATION = 300

    def __init__(self, server_url: str, token: str, emoji: str, ttl: int):
        self.endpoint = server_url
        self.token = token
        self.emoji = emoji
        self.ttl = ttl
        self.session = requests.Session()
        self.session.headers.update({
            &quot;Content-Type&quot;: &quot;application/json&quot;,
            &quot;Accept&quot;: &quot;application/json&quot;,
            &quot;User-Agent&quot;: (
                &quot;Mozilla/5.0 (Windows NT 10.0; Win64; x64) &quot;
                &quot;AppleWebKit/537.36 (KHTML, like Gecko) &quot;
                &quot;Chrome/124.0.0.0 Safari/537.36&quot;
            ),
        })
        self._consecutive_failures = 0
        self._current_backoff = 0
        self._pause_until = 0.0

    def send(self, desc: str) -&gt; bool:
        if self.pause_remaining &gt; 0:
            return False

        payload = {
            &quot;key&quot;: self.token,
            &quot;emoji&quot;: self.emoji,
            &quot;desc&quot;: desc[:256],
            &quot;ttl&quot;: self.ttl,
        }
        try:
            resp = self.session.post(self.endpoint, json=payload, timeout=10)
            if resp.status_code in (200, 201, 409):
                self._consecutive_failures = 0
                self._current_backoff = 0
                self._pause_until = 0.0
                return True
            log.warning(&quot;Server %d: %s&quot;, resp.status_code, resp.text[:200])
        except requests.RequestException as e:
            log.warning(&quot;Request failed: %s&quot;, e)

        self._consecutive_failures += 1
        if self._current_backoff == 0:
            self._current_backoff = 5
        else:
            self._current_backoff = min(self._current_backoff * 2, self.MAX_BACKOFF)

        if self._consecutive_failures &gt;= self.PAUSE_AFTER_FAILURES:
            log.warning(&quot;Failed %d times, pausing %ds&quot;, self._consecutive_failures, self.PAUSE_DURATION)
            self._pause_until = time.monotonic() + self.PAUSE_DURATION
            self._consecutive_failures = 0
            self._current_backoff = 0
        return False

    @property
    def backoff(self) -&gt; float:
        return self._current_backoff

    @property
    def pause_remaining(self) -&gt; float:
        remaining = self._pause_until - time.monotonic()
        if remaining &lt;= 0:
            self._pause_until = 0.0
            return 0.0
        return remaining

    @property
    def retry_delay(self) -&gt; float:
        return self.pause_remaining or self.backoff


# ---------------------------------------------------------------------------
# System Tray
# ---------------------------------------------------------------------------
shutdown_event = threading.Event()


def _make_tray_icon(color: str = &quot;green&quot;) -&gt; &quot;PIL.Image.Image&quot;:
    &quot;&quot;&quot;Generate a colored circle icon for the system tray.&quot;&quot;&quot;
    from PIL import Image, ImageDraw
    size = 64
    img = Image.new(&quot;RGBA&quot;, (size, size), (0, 0, 0, 0))
    draw = ImageDraw.Draw(img)
    colors = {&quot;green&quot;: (76, 175, 80), &quot;orange&quot;: (255, 152, 0), &quot;gray&quot;: (158, 158, 158)}
    rgb = colors.get(color, colors[&quot;gray&quot;])
    draw.ellipse([8, 8, size - 8, size - 8], fill=(*rgb, 255))
    return img


class TrayAgent:
    &quot;&quot;&quot;System tray with Chinese UI, hover tooltip, and integrated settings.&quot;&quot;&quot;

    def __init__(self):
        import pystray
        self._pystray = pystray
        self._lock = threading.Lock()
        self._status = &quot;初始化中&quot;
        self._current_target = &quot;&quot;
        self._icon: pystray.Icon | None = None
        self._settings_requested = False
        self._icons = {
            &quot;green&quot;: _make_tray_icon(&quot;green&quot;),
            &quot;orange&quot;: _make_tray_icon(&quot;orange&quot;),
            &quot;gray&quot;: _make_tray_icon(&quot;gray&quot;),
        }

    def _build_menu(self):
        p = self._pystray
        return p.Menu(
            p.MenuItem(lambda _: f&quot;状态: {self._get_status()}&quot;, None, enabled=False),
            p.MenuItem(lambda _: f&quot;当前: {self._get_current() or &#x27;无&#x27;}&quot;, None, enabled=False),
            p.Menu.SEPARATOR,
            p.MenuItem(&quot;日志文件&quot;, self._toggle_log,
                       checked=lambda _: _file_handler is not None),
            p.MenuItem(&quot;开机自启&quot;, self._toggle_autostart,
                       checked=lambda _: is_autostart_enabled()),
            p.MenuItem(&quot;设置&quot;, self._open_settings),
            p.Menu.SEPARATOR,
            p.MenuItem(&quot;退出&quot;, self._quit),
        )

    def _get_status(self) -&gt; str:
        with self._lock:
            return self._status

    def _get_current(self) -&gt; str:
        with self._lock:
            return self._current_target

    def update_status(self, status: str, current_target: str | None = None):
        with self._lock:
            self._status = status
            if current_target is not None:
                self._current_target = current_target
            current_target_value = self._current_target
        if self._icon:
            color = {&quot;在线&quot;: &quot;green&quot;}.get(status, &quot;gray&quot;)
            self._icon.icon = self._icons[color]
            # Hover tooltip shows current app + status.
            tip = &quot;Live Dashboard&quot;
            if current_target_value:
                tip += f&quot;\n当前: {current_target_value}&quot;
            tip += f&quot;\n{status}&quot;
            self._icon.title = tip[:127]

    def _toggle_log(self):
        enabled = _file_handler is None
        set_file_logging(enabled)
        cfg = load_config()
        cfg[&quot;enable_log&quot;] = enabled
        save_config(cfg)
        if self._icon:
            self._icon.update_menu()

    def _toggle_autostart(self):
        enabled = is_autostart_enabled()
        if enabled:
            registry_ok = _set_registry_autostart(False)
            legacy_ok = _remove_legacy_startup_task()
            if registry_ok and legacy_ok:
                log.info(&quot;Autostart disabled&quot;)
            else:
                show_message(
                    &quot;Live Dashboard&quot;,
                    &quot;关闭开机自启时未能清理全部启动项。\n请检查任务计划程序中的 LiveDashboardAgent。&quot;,
                    error=True,
                )
        else:
            if _set_registry_autostart(True):
                log.info(&quot;Autostart enabled&quot;)
            else:
                show_message(
                    &quot;Live Dashboard&quot;,
                    &quot;无法开启开机自启，请检查当前账户是否有写入启动项的权限。&quot;,
                    error=True,
                )
        if self._icon:
            self._icon.update_menu()

    def _open_settings(self):
        self._settings_requested = True
        if self._icon:
            self._icon.stop()

    def _quit(self):
        shutdown_event.set()
        if self._icon:
            self._icon.stop()
        logging.shutdown()
        os._exit(0)

    @property
    def settings_requested(self) -&gt; bool:
        return self._settings_requested

    def run(self):
        &quot;&quot;&quot;Run the tray icon (blocking; call from main thread).&quot;&quot;&quot;
        icon_path = base_dir / &quot;icon.ico&quot;
        if icon_path.exists():
            from PIL import Image
            with Image.open(icon_path) as im:
                icon_img = im.copy()
        else:
            icon_img = _make_tray_icon(&quot;gray&quot;)
        self._icon = self._pystray.Icon(
            &quot;live-dashboard&quot;,
            icon_img,
            &quot;Live Dashboard&quot;,
            menu=self._build_menu(),
        )
        self._icon.run()


# ---------------------------------------------------------------------------
# Monitor loop
# ---------------------------------------------------------------------------
def _monitor_loop(cfg: dict, reporter: Reporter, tray: TrayAgent | None) -&gt; None:
    heartbeat_interval = cfg[&quot;heartbeat_seconds&quot;]
    
    sensitive_regex_str = cfg.get(&quot;sensitive_words_regex&quot;, &quot;&quot;)
    sensitive_regex = None
    if sensitive_regex_str:
        import re
        try:
            sensitive_regex = re.compile(sensitive_regex_str)
        except re.error:
            pass

    prev_app: str | None = None
    prev_title: str | None = None
    last_report_time: float = 0

    log.info(
        &quot;Monitoring - poll=%ds, heartbeat=%ds&quot;,
        MONITOR_POLL_SECONDS, heartbeat_interval,
    )

    while not shutdown_event.is_set():
        try:
            now = time.time()

            info = get_foreground_info()
            if info is None:
                shutdown_event.wait(MONITOR_POLL_SECONDS)
                continue

            app_id, title = info

            if sensitive_regex and sensitive_regex.search(title):
                title = &quot;&quot;

            # Keep tray status responsive; current item is updated only after a successful report.
            if tray:
                tray.update_status(&quot;在线&quot;)

            changed = app_id != prev_app or title != prev_title
            heartbeat_due = (now - last_report_time) &gt;= heartbeat_interval

            if changed or heartbeat_due:
                reported_target = format_report_desc(app_id, title)
                success = reporter.send(reported_target)
                if success:
                    prev_app = app_id
                    prev_title = title
                    last_report_time = now
                    if tray:
                        tray.update_status(&quot;在线&quot;, reported_target)
                    if changed:
                        log.info(&quot;Reported: %s&quot;, reported_target)
                elif reporter.retry_delay &gt; 0:
                    shutdown_event.wait(reporter.retry_delay)
                    continue

            shutdown_event.wait(MONITOR_POLL_SECONDS)

        except Exception as e:
            log.error(&quot;Error: %s&quot;, e, exc_info=True)
            shutdown_event.wait(MONITOR_POLL_SECONDS)

    log.info(&quot;Monitor stopped&quot;)


# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------
def main() -&gt; None:
    log.info(&quot;Live Dashboard Windows Agent&quot;)

    while True:
        cfg = load_config()

        # No valid config 鈫?show settings dialog
        if not cfg.get(&quot;server_url&quot;) or not cfg.get(&quot;token&quot;) or cfg.get(&quot;token&quot;) == &quot;YOUR_TOKEN_HERE&quot;:
            cfg = show_settings_dialog(cfg)
            if cfg is None:
                return
            cfg = load_config()

        err = validate_config(cfg)
        if err:
            log.warning(&quot;Invalid config: %s&quot;, err)
            cfg = show_settings_dialog(cfg)
            if cfg is None:
                return
            cfg = load_config()
            continue

        # Apply log preference
        set_file_logging(cfg.get(&quot;enable_log&quot;, False))
        if cfg.get(&quot;enable_log&quot;):
            log.info(&quot;HTTP: %s&quot;, &quot;HTTPS&quot; if cfg[&quot;server_url&quot;].startswith(&quot;https&quot;) else &quot;HTTP (内网)&quot;)

        reporter = Reporter(
            cfg[&quot;server_url&quot;],
            cfg[&quot;token&quot;],
            cfg.get(&quot;emoji&quot;, &quot;&quot;),
            cfg[&quot;heartbeat_seconds&quot;],
        )

        tray: TrayAgent | None = None
        try:
            tray = TrayAgent()
        except ImportError:
            log.warning(&quot;pystray/Pillow not installed, running without tray&quot;)
        except Exception as e:
            log.warning(&quot;Tray init failed: %s&quot;, e)

        if tray:
            monitor = threading.Thread(
                target=_monitor_loop, args=(cfg, reporter, tray), daemon=True
            )
            monitor.start()
            tray.run()  # Blocks until quit or settings
            shutdown_event.set()
            monitor.join(timeout=5)

            if tray.settings_requested:
                shutdown_event.clear()
                new_cfg = show_settings_dialog(cfg)
                if new_cfg is None:
                    continue  # Cancelled, restart with old config
                continue  # Restart with new config
            else:
                break  # Quit
        else:
            try:
                _monitor_loop(cfg, reporter, None)
            except KeyboardInterrupt:
                pass
            break

    log.info(&quot;Agent stopped&quot;)


if __name__ == &quot;__main__&quot;:
    main()

</code></pre>
<p>同级目录创建配置文件<code>config.json</code>:</p><pre class="language-json lang-json"><code class="language-json lang-json">{
  &quot;server_url&quot;: &quot;https://example.com/api/v2/fn/shiro/status&quot;,
  &quot;token&quot;: &quot;&lt;YOUR_API_KEY&gt;&quot;,
  &quot;emoji&quot;: &quot;💻&quot;,
  &quot;heartbeat_seconds&quot;: 60,
  &quot;sensitive_words_regex&quot;: &quot;(?:敏感词1|敏感词2|敏感词3)|(?:[a-zA-Z]:\\\\[^&lt;&gt;:\&quot;/|?*]*)&quot;, //正则式过滤敏感和文件路径
  &quot;enable_log&quot;: false
}
</code></pre>
<h2 id="agent">优化函数以处理多Agent</h2><p>为了处理两个设备同时对api上报的情况，修改了以下函数，默认优先展示电脑的状态：</p><pre class="language-ts lang-ts"><code class="language-ts lang-ts">interface Status {
  emoji: string
  icon?: string
  desc?: string
  ttl: number
  untilAt: number
  device: &#x27;desktop&#x27; | &#x27;phone&#x27;
  updatedAt: number
}

type Device = Status[&#x27;device&#x27;]

const cachePrefix = &#x27;shiro:status&#x27;
const legacyCacheKey = &#x27;shiro:status&#x27;
const desktopCacheKey = `${cachePrefix}:desktop`
const phoneCacheKey = `${cachePrefix}:phone`

const DEFAULT_TTL = 86400 // 1 day
const MAX_TTL = 7 * 86400 // 7 days

function getBody(ctx: Context) {
  const body = ctx.req.body

  if (!body) return {}

  if (typeof body === &#x27;string&#x27;) {
    try {
      return JSON.parse(body)
    } catch {
      return {}
    }
  }

  return body
}

function assertAuth(ctx: Context) {
  const body = getBody(ctx)
  const authKey = ctx.secret?.key

  if (ctx.isAuthenticated) return

  if (!authKey) {
    ctx.throws(500, &#x27;Missing auth key&#x27;)
  }

  if (body.key !== authKey) {
    ctx.throws(401, &#x27;Unauthorized&#x27;)
  }
}

export default async function handler(ctx: Context) {
  const method = String(ctx.req.method || &#x27;&#x27;).toLowerCase()

  switch (method) {
    case &#x27;get&#x27;:
      return await GET(ctx)

    case &#x27;post&#x27;:
      assertAuth(ctx)
      return await POST(ctx)

    case &#x27;delete&#x27;:
      assertAuth(ctx)
      return await DELETE(ctx)

    case &#x27;options&#x27;:
      return OPTIONS()

    default:
      ctx.throws(405, &#x27;Method Not Allowed&#x27;)
  }
}

function getCacheKeyByDevice(device: Device) {
  return device === &#x27;desktop&#x27; ? desktopCacheKey : phoneCacheKey
}

function normalizeEmoji(input: unknown) {
  let text = String(input ?? &#x27;&#x27;).trim()

  try {
    if (/%[0-9A-Fa-f]{2}/.test(text)) {
      text = decodeURIComponent(text)
    }
  } catch {
    // ignore
  }

  try {
    if (/\\u[0-9A-Fa-f]{4}/.test(text)) {
      text = JSON.parse(`&quot;${text.replace(/&quot;/g, &#x27;\\&quot;&#x27;)}&quot;`)
    }
  } catch {
    // ignore
  }

  return text
    .normalize(&#x27;NFC&#x27;)
    .replace(/\uFE0F/g, &#x27;&#x27;)
    .trim()
}

function detectDevice(rawEmoji: unknown): Device | null {
  const emoji = normalizeEmoji(rawEmoji)

  if (emoji.includes(&#x27;💻&#x27;)) return &#x27;desktop&#x27;
  if (emoji.includes(&#x27;📱&#x27;)) return &#x27;phone&#x27;

  return null
}

function normalizeTTL(input: unknown) {
  const ttl = Number(input ?? DEFAULT_TTL)

  if (!Number.isFinite(ttl) || ttl &lt;= 0) {
    return DEFAULT_TTL
  }

  return Math.min(Math.floor(ttl), MAX_TTL)
}

function parseStatus(raw: unknown): Status | null {
  if (!raw) return null

  try {
    const status = typeof raw === &#x27;string&#x27; ? JSON.parse(raw) : raw

    if (!status || typeof status !== &#x27;object&#x27;) return null
    if (typeof status.emoji !== &#x27;string&#x27;) return null
    if (typeof status.untilAt !== &#x27;number&#x27;) return null

    if (status.untilAt &lt;= Date.now()) return null

    return status as Status
  } catch {
    return null
  }
}

async function getStatusByKey(ctx: Context, key: string) {
  const raw = await ctx.storage.cache.get(key)
  return parseStatus(raw)
}

async function getActiveStatus(ctx: Context): Promise&lt;Status | null&gt; {
  const desktopStatus = await getStatusByKey(ctx, desktopCacheKey)
  const phoneStatus = await getStatusByKey(ctx, phoneCacheKey)

  // 两个设备都有效时，优先返回电脑状态
  if (desktopStatus) return desktopStatus
  if (phoneStatus) return phoneStatus

  return null
}

async function DELETE(ctx: Context) {
  await ctx.storage.cache.del(desktopCacheKey)
  await ctx.storage.cache.del(phoneCacheKey)

  // 顺手清理旧版本遗留缓存
  await ctx.storage.cache.del(legacyCacheKey)

  ctx.broadcast(&#x27;shiro#status&#x27;, null)

  return null
}

async function POST(ctx: Context) {
  const body = getBody(ctx)

  const rawEmoji = body.emoji
  const emoji = normalizeEmoji(rawEmoji)

  if (!emoji) {
    ctx.throws(400, &#x27;Missing emoji&#x27;)
  }

  const device = detectDevice(emoji)

  if (!device) {
    ctx.throws(
      400,
      `Emoji must contain 📱 or 💻, received: ${JSON.stringify(rawEmoji)}`
    )
  }

  const ttl = normalizeTTL(body.ttl)
  const now = Date.now()

  const status: Status = {
    emoji,
    icon: typeof body.icon === &#x27;string&#x27; ? body.icon : undefined,
    desc: typeof body.desc === &#x27;string&#x27; ? body.desc : undefined,
    ttl,
    untilAt: now + ttl * 1000,
    updatedAt: now,
    device,
  }

  await ctx.storage.cache.set(
    getCacheKeyByDevice(device),
    JSON.stringify(status),
    ttl
  )

  const activeStatus = await getActiveStatus(ctx)

  ctx.broadcast(&#x27;shiro#status&#x27;, activeStatus)

  return activeStatus ? JSON.stringify(activeStatus) : null
}

async function GET(ctx: Context) {
  const activeStatus = await getActiveStatus(ctx)

  return activeStatus ? JSON.stringify(activeStatus) : null
}

function OPTIONS() {
  return null
}

</code></pre>
<p>:::note
  这里通过 emoji <code>📱</code>和<code>💻</code>来区分 Android 和 Windows Agent，这是为了前端展示美观。你也可以换为其他字符。
:::</p>
<hr/><p><del>享受被视奸的快感吧</del></p></div><p style="text-align:right"><a href="https://elvish.me/posts/archive/shiro-status-function-and-crossplatform-agent#comments">览毕，何不一言？</a></p></div>]]></description><link>https://elvish.me/posts/archive/shiro-status-function-and-crossplatform-agent</link><guid isPermaLink="true">https://elvish.me/posts/archive/shiro-status-function-and-crossplatform-agent</guid><dc:creator><![CDATA[Elvish]]></dc:creator><pubDate>Sun, 26 Apr 2026 14:40:00 GMT</pubDate></item><item><title><![CDATA[GitHub Actions 自动清除腾讯云 EdgeOne CDN 缓存]]></title><description><![CDATA[<div><blockquote>此渲染由 Yohaku API 生成，或存排版之虞，最佳体验请往：<a href="https://elvish.me/posts/coding/github-actions-clear-tencent-edgeone-cdn-cache">https://elvish.me/posts/coding/github-actions-clear-tencent-edgeone-cdn-cache</a></blockquote><div><p>在Github Action构建完 Yohaku 部署到服务器后自动清除CDN缓存，以立即应用新的更改</p><p>适用于</p><p><a href="https://github.com/innei-dev/yohaku-deploy-action/">https://github.com/innei-dev/yohaku-deploy-action/</a></p><p>:::note
使用下面的代码注意评估源站的承受能力
:::</p>
<p>需要添加的Secret有：</p><table><thead><tr><th> 名称 </th><th> 示例值 </th><th> 说明 </th></tr></thead><tbody><tr><td> TENCENT_CLOUD_SECRET_ID </td><td> AKXXXXXXXXXX </td><td> 你的腾讯云Secret Id </td></tr><tr><td> TENCENT_CLOUD_SECRET_KEY </td><td> BHXXXXXXXXXX </td><td> 你的腾讯云Secret Key </td></tr><tr><td> TENCENT_CDN_DOMAIN </td><td> example.com　　 </td><td>你的博客前端域名</td></tr><tr><td> TENCENT_EDGEONE_ZONE_ID </td><td> zone-123456abc </td><td> Edgeone实例的Zone Id，可以在 <ins><a href="https://console.cloud.tencent.com/edgeone/zones">控制台</a></ins> 查看</td></tr></tbody></table>
<p>修改<code>depoly.yml</code></p><pre class="language-yml lang-yml"><code class="language-yml lang-yml">//约第 236 行附近

ClearCDNCache:
    name: Clear Tencent EdgeOne CDN cache
    runs-on: ubuntu-latest
    needs: deploy
    steps:
      - name: Set up Node.js
        uses: actions/setup-node@v4
        with:
          node-version: &#x27;20&#x27;

      - name: Install Tencent Cloud SDK
        run: npm install tencentcloud-sdk-nodejs --no-save --no-package-lock

      - name: Purge cache with Tencent Cloud SDK
        env:
          TENCENT_CLOUD_SECRET_ID: ${{ secrets.TENCENT_CLOUD_SECRET_ID }}
          TENCENT_CLOUD_SECRET_KEY: ${{ secrets.TENCENT_CLOUD_SECRET_KEY }}
          TENCENT_CDN_DOMAIN: ${{ secrets.TENCENT_CDN_DOMAIN }}
          TENCENT_EDGEONE_ZONE_ID: ${{ secrets.TENCENT_EDGEONE_ZONE_ID }}
        run: |
          cat &gt; clearCache.js &lt;&lt;&#x27;EOF&#x27;
          const tencentcloud = require(&quot;tencentcloud-sdk-nodejs&quot;);

          const requiredEnv = [
            &quot;TENCENT_CLOUD_SECRET_ID&quot;,
            &quot;TENCENT_CLOUD_SECRET_KEY&quot;,
            &quot;TENCENT_CDN_DOMAIN&quot;,
            &quot;TENCENT_EDGEONE_ZONE_ID&quot;,
          ];

          const missingEnv = requiredEnv.filter((name) =&gt; !process.env[name] || !process.env[name].trim());
          if (missingEnv.length &gt; 0) {
            console.error(`Missing required environment variables: ${missingEnv.join(&quot;, &quot;)}`);
            process.exit(1);
          }

          const TeoClient = tencentcloud.teo.v20220901.Client;
          const client = new TeoClient({
            credential: {
              secretId: process.env.TENCENT_CLOUD_SECRET_ID,
              secretKey: process.env.TENCENT_CLOUD_SECRET_KEY,
            },
            region: &quot;&quot;,
            profile: {
              httpProfile: {
                endpoint: &quot;teo.tencentcloudapi.com&quot;,
              },
            },
          });

          const params = {
            Targets: [process.env.TENCENT_CDN_DOMAIN.trim()],
            Type: &quot;purge_host&quot;,
            ZoneId: process.env.TENCENT_EDGEONE_ZONE_ID.trim(),
          };

          async function main() {
            const data = await client.CreatePurgeTask(params);
            console.log(JSON.stringify(data, null, 2));

            const failedList = data?.FailedList ?? data?.Response?.FailedList ?? [];
            if (Array.isArray(failedList) &amp;&amp; failedList.length &gt; 0) {
              console.error(&quot;Tencent EdgeOne purge task contains failed targets:&quot;);
              console.error(JSON.stringify(failedList, null, 2));
              process.exit(1);
            }
          }

          main().catch((error) =&gt; {
            console.error(&quot;Tencent EdgeOne purge failed:&quot;, error);
            process.exit(1);
          });
          EOF

          node clearCache.js

//约第 315 行附近
    needs: [ClearCDNCache, build]
</code></pre>
<p>修改后推送触发Action，查看日志，输出</p><pre class=""><code class="">{
  &quot;RequestId&quot;: &quot;xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxx&quot;,
  &quot;FailedList&quot;: [],
  &quot;JobId&quot;: &quot;xxxxxxxxx&quot;
}
</code></pre>
<p>代表清除缓存成功。</p></div><p style="text-align:right"><a href="https://elvish.me/posts/coding/github-actions-clear-tencent-edgeone-cdn-cache#comments">览毕，何不一言？</a></p></div>]]></description><link>https://elvish.me/posts/coding/github-actions-clear-tencent-edgeone-cdn-cache</link><guid isPermaLink="true">https://elvish.me/posts/coding/github-actions-clear-tencent-edgeone-cdn-cache</guid><dc:creator><![CDATA[Elvish]]></dc:creator><pubDate>Sun, 26 Apr 2026 11:07:26 GMT</pubDate></item><item><title><![CDATA[在Yohaku首页中添加更多社交链接]]></title><description><![CDATA[<div><blockquote>此渲染由 Yohaku API 生成，或存排版之虞，最佳体验请往：<a href="https://elvish.me/posts/coding/yohaku-add-social-links">https://elvish.me/posts/coding/yohaku-add-social-links</a></blockquote><div><h2 id="">引入</h2><p>部署完Yohaku后注意到mx-space管理后台只能设置<code>Github</code>, <code>Weibo</code>, <code>哔哩哔哩</code>, <code>网易云音乐</code>这四个社交链接。但是在 <ins><a href="https://innei.in">Innei</a></ins> 的主页可以看到不止这四个链接</p>
<h2 id="">分析</h2><p>:::warning
以下做法仅供参考，<del>因为有可能可以在<code>云函数</code>部分配置，但是我没找到</del>
:::</p><p>通过在mx-space后台更新社交链接时打开控制台或抓包易知：
浏览器通过请求 <code>PATCH https://example.com/api/v2/owner</code> 来更新社交链接</p><p>请求体为</p><pre class="language-json lang-json"><code class="language-json lang-json">{
  &quot;socialIds&quot;: {
    &quot;github&quot;: &quot;username&quot;,
    &quot;bilibili&quot;: &quot;id&quot;
    // ...
  }
}
</code></pre>
<p>分析源代码<code>SocialIcon.tsx</code>：</p><pre class="language-tsx lang-tsx"><code class="language-tsx lang-tsx">//第 19 行附近
export const socialIconSet: Record&lt;
  string,
  [string, ReactNode, string, (id: string) =&gt; string]
&gt; = {
  github: [
    &#x27;Github&#x27;,
    &lt;i className=&quot;i-mingcute-github-line&quot; /&gt;,
    &#x27;#181717&#x27;,
    (id) =&gt; `https://github.com/${id}`,
  ],
  twitter: [
    &#x27;Twitter&#x27;,
    &lt;i className=&quot;i-mingcute-twitter-line&quot; /&gt;,
    &#x27;#1DA1F2&#x27;,
    (id) =&gt; `https://twitter.com/${id}`,
  ],
  x: [&#x27;X&#x27;, &lt;XIcon /&gt;, &#x27;rgba(36,46,54,1.00)&#x27;, (id) =&gt; `https://x.com/${id}`],
  telegram: [
    &#x27;Telegram&#x27;,
    &lt;i className=&quot;i-mingcute-telegram-line&quot; /&gt;,
    &#x27;#0088cc&#x27;,
    (id) =&gt; `https://t.me/${id}`,
  ],
  mail: [
    &#x27;Email&#x27;,
    &lt;i className=&quot;i-mingcute-mail-line&quot; /&gt;,
    &#x27;#D44638&#x27;,
    (id) =&gt; `mailto:${id}`,
  ],
  get email() {
    return this.mail
  },
  get feed() {
    return this.rss
  },
  rss: [&#x27;RSS&#x27;, &lt;i className=&quot;i-mingcute-rss-line&quot; /&gt;, &#x27;#FFA500&#x27;, (id) =&gt; id],
  bilibili: [
    &#x27;哔哩哔哩&#x27;,
    &lt;BilibiliIcon /&gt;,
    &#x27;#00A1D6&#x27;,
    (id) =&gt; `https://space.bilibili.com/${id}`,
  ],
  netease: [
    &#x27;网易云音乐&#x27;,
    &lt;NeteaseCloudMusicIcon /&gt;,
    &#x27;#C20C0C&#x27;,
    (id) =&gt; `https://music.163.com/#/user/home?id=${id}`,
  ],
  qq: [
    &#x27;QQ&#x27;,
    &lt;i className=&quot;i-mingcute-qq-fill&quot; /&gt;,
    &#x27;#1e6fff&#x27;,
    (id) =&gt; `https://wpa.qq.com/msgrd?v=3&amp;uin=${id}&amp;site=qq&amp;menu=yes`,
  ],
  wechat: [
    &#x27;微信&#x27;,
    &lt;i className=&quot;i-mingcute-wechat-fill&quot; /&gt;,
    &#x27;#2DC100&#x27;,
    (id) =&gt; id,
  ],
  weibo: [
    &#x27;微博&#x27;,
    &lt;i className=&quot;i-mingcute-weibo-line&quot; /&gt;,
    &#x27;#E6162D&#x27;,
    (id) =&gt; `https://weibo.com/${id}`,
  ],
  discord: [
    &#x27;Discord&#x27;,
    &lt;i className=&quot;i-mingcute-discord-fill&quot; /&gt;,
    &#x27;#7289DA&#x27;,
    (id) =&gt; `https://discord.gg/${id}`,
  ],
  bluesky: [
    &#x27;Bluesky&#x27;,
    &lt;BlueskyIcon /&gt;,
    &#x27;#0085FF&#x27;,
    (id) =&gt; `https://bsky.app/profile/${id}`,
  ],
  steam: [
    &#x27;Steam&#x27;,
    &lt;SteamIcon /&gt;,
    &#x27;#0F1C30&#x27;,
    (id) =&gt; `https://steamcommunity.com/id/${id}`,
  ],
}
</code></pre>
<p>可知，还可以定义的有</p><ul><li>Twitter</li><li>X</li><li>Telegram</li><li>QQ</li><li>Wechat</li><li>mail</li><li>RSS</li><li>Bluesky</li><li>Discord</li><li>Steam</li></ul><p>:::note
由于QQ临时会话的接口已不再适用，可以改为直接跳转到 <code>id</code>，并填入 添加好友 或 入群链接</p><pre class="language-tsx lang-tsx"><code class="language-tsx lang-tsx">  qq: [
    &#x27;QQ&#x27;,
    &lt;i className=&quot;i-mingcute-qq-fill&quot; /&gt;,
    &#x27;#1e6fff&#x27;,
    (id) =&gt; id,
  ],
</code></pre><p>:::</p><h2 id="">添加更多链接</h2><p>因此可以通过 Reqable 抓包然后加上这部分内容再发送给服务器更新社交链接</p><p>示例：</p><pre class="language-json lang-json"><code class="language-json lang-json">{
  &quot;socialIds&quot;: {
    &quot;github&quot;: &quot;Elvish064&quot;,
    &quot;bilibili&quot;: &quot;1613372234&quot;,
    &quot;X&quot;: &quot;Elvish064&quot;,
    &quot;telegram&quot;: &quot;Elvish064&quot;,
    &quot;qq&quot;: &quot;https://qm.qq.com/cgi-bin/qm/qr?k=bLl4uuTl3BK2rKissNgVgdHyk22korJ4&amp;jump_from=webapi&amp;authKey=XIkvyvxvUHQ+MrpO3evV827wT3bHzxNtEYTzxKvqgbInpbPe4scR3S2JJuQSa3nY&quot;,
    &quot;mail&quot;: &quot;elvish@elvish.me&quot;,
    &quot;rss&quot;: &quot;https://www.elvish.me/feed&quot;
  }
}
</code></pre>
<p>现在刷新一下主页，应该也有一排数个社交链接了。</p></div><p style="text-align:right"><a href="https://elvish.me/posts/coding/yohaku-add-social-links#comments">览毕，何不一言？</a></p></div>]]></description><link>https://elvish.me/posts/coding/yohaku-add-social-links</link><guid isPermaLink="true">https://elvish.me/posts/coding/yohaku-add-social-links</guid><dc:creator><![CDATA[Elvish]]></dc:creator><pubDate>Fri, 24 Apr 2026 15:38:38 GMT</pubDate></item><item><title><![CDATA[让Yohaku同时展示萌ICP与标准ICP备案]]></title><description><![CDATA[<div><blockquote>此渲染由 Yohaku API 生成，或存排版之虞，最佳体验请往：<a href="https://elvish.me/posts/coding/yohaku-add-moeicp">https://elvish.me/posts/coding/yohaku-add-moeicp</a></blockquote><div><h2 id="">引入</h2><p>原版的Yohaku只能展示一个ICP，在云函数部分配置:</p><pre class="language-json lang-json"><code class="language-json lang-json">{
  &quot;footer&quot;: {
    &quot;otherInfo&quot;: {
      &quot;date&quot;: &quot;2024-{{now}}&quot;,
      &quot;icp&quot;: {
        &quot;text&quot;: &quot;萌 ICP 备 20250030 号&quot;,
        &quot;link&quot;: &quot;https://icp.gov.moe/?keyword=20250030&quot;
      }
    },
    //....
</code></pre>
<p>但是对于既有萌ICP，又有标准ICP备案的情况就左右为难了</p><h2 id="">修改源码</h2><p>通过修改两个文件可以实现展示多个备案号：</p><p>在<code>app.config.d.ts</code>的 36 行附近添加新定义的<code>moeicp</code></p><pre class="language-tsx lang-tsx"><code class="language-tsx lang-tsx">//第 36 行附近
  export interface OtherInfo {
    date: string
    icp?: {
      text: string
      link: string
    }
    moeicp?: {
      text: string
      link: string
    }
  }
</code></pre>
<p>在<code>FooterInfo.tsx</code>的 36 行附近添加新定义的<code>moeicp</code></p><pre class="language-tsx lang-tsx"><code class="language-tsx lang-tsx">//第 35 行附近
  const { date: dateRaw, icp } = otherInfo || {}
  const { date: dateRaw, icp, moeicp } = otherInfo || {}
  const displayMoeIcp = moeicp
  
//第 98 行附近
            {(icp || displayMoeIcp) &amp;&amp; (
              &lt;div className=&quot;mt-0.5 flex flex-wrap items-center gap-4&quot;&gt;
                {icp &amp;&amp; (
                  &lt;StyledLink external href={icp.link} rel=&quot;noreferrer&quot;&gt;
                    {icp.text}
                  &lt;/StyledLink&gt;
                )}
                {displayMoeIcp &amp;&amp; (
                  &lt;StyledLink external href={displayMoeIcp.link} rel=&quot;noreferrer&quot;&gt;
                    {displayMoeIcp.text}
                  &lt;/StyledLink&gt;
                )}
              &lt;/div&gt;
            )}

//第 155 行附近
   &lt;/span&gt;
        {(icp || displayMoeIcp) &amp;&amp; (
          &lt;span className=&quot;flex items-center gap-4 text-sm text-neutral-6&quot;&gt;
            {icp &amp;&amp; (
              &lt;StyledLink external href={icp.link} rel=&quot;noreferrer&quot;&gt;
                {icp.text}
              &lt;/StyledLink&gt;
            )}
            {displayMoeIcp &amp;&amp; (
              &lt;StyledLink external href={displayMoeIcp.link} rel=&quot;noreferrer&quot;&gt;
                {displayMoeIcp.text}
              &lt;/StyledLink&gt;
            )}
          &lt;/span&gt;
        )}
      &lt;/div&gt;
</code></pre>
<h2 id="">新增配置文件</h2><p>增加云函数部分的配置，以展示两个备案号了</p><pre class="language-json lang-json"><code class="language-json lang-json">{
  &quot;footer&quot;: {
    &quot;otherInfo&quot;: {
      &quot;date&quot;: &quot;2020-{{now}}&quot;,
      &quot;icp&quot;: {
        &quot;text&quot;: &quot;渝 ICP 备 2025073510 号&quot;,
        &quot;link&quot;: &quot;https://beian.miit.gov.cn/&quot;
      },
      &quot;moeicp&quot;: {
        &quot;text&quot;: &quot;萌 ICP 备 20250030 号&quot;,
        &quot;link&quot;: &quot;https://icp.gov.moe/?keyword=20250030&quot;
      }
    },
    //....
</code></pre></div><p style="text-align:right"><a href="https://elvish.me/posts/coding/yohaku-add-moeicp#comments">览毕，何不一言？</a></p></div>]]></description><link>https://elvish.me/posts/coding/yohaku-add-moeicp</link><guid isPermaLink="true">https://elvish.me/posts/coding/yohaku-add-moeicp</guid><dc:creator><![CDATA[Elvish]]></dc:creator><pubDate>Fri, 24 Apr 2026 12:33:15 GMT</pubDate></item></channel></rss>