Compare commits

...
8 Commits
Author SHA1 Message Date
nomdn 805b65bc07 fix(node): ASN WHOIS 支持多 RIR 解析,补 ASN 库兜底与缓存键归一化
现象:/v1/asn/:ip 与 ws 的 asn 路径,除 1.1.1.1(AS13335,ARIN 注册)外全部拿不到 whois 信息。

解析侧 src/webtest/whois.go
- 根因:parseASNWhoisRaw 只认 ARIN 字段名(ASNumber/ASName/OrgName/OrgAbuse*),
  而 QueryASNWhois 走 IANA → 归属 RIR 链路,非 ARIN 的 ASN 回的是 aut-num/
  as-name/descr/country/last-modified 格式,于是字段解析全空
- 另一坑:ARIN 对非 ARIN 资源只回转介桩(ASName: APNIC-4134 + ReferralServer),
  同一条 raw 里存在两段内容,桩的字段是误导信息
- 改为按空行分块解析:存在原生 aut-num 块时以它为准、整体忽略 ARIN 段;字段按 RIR
  分级取值(RIPE 的组织名与国别按 aut-num 的 org: 句柄定位 organisation 对象)
- abuse 优先取 % Abuse contact 注释行,其次含 abuse-mailbox 的对象
- 实网验证 22/22 全解析(电信/联通/移动/阿里/腾讯/百度/华为 + CF/Google/Amazon/
  Hetzner/Linode 等),覆盖 ARIN+APNIC+RIPE 三类 RIR

消费侧 src/main.go src/ws.go
- 新增 resolveASN():ASN 来源按 maxmind → dbip → ip2location 逐级兜底。此前只认
  maxmind_asn,某 IP 未命中该库时整段 whois 直接消失,哪怕另两个库有数据
- 新增 normalizeASN():MMDBASNResult.ASN 自带 "AS" 前缀,ip2location 的 asn 是裸数字,
  缓存键与查询入参统一归一化(此前 "AS"+ASN 实际拼成 ASAS13335)
- 新增 lookupASNWhois():handler 与 ws 共用同一套缓存 —— ws 路径此前完全没有缓存,
  每个请求都真查一次 WHOIS
- 新增 asnWhoisFailed():likexian/whois 出错时返回 (result, nil),错误塞在 result.Error
  里;老代码只判 err != nil(恒为假)⇒ 失败结果被当成功缓存 5min,现按失败走 30s TTL
2026-10-05 20:51:01 +08:00
nomdn 682983e411 fix(frontend-ssr): 手机端两 IP 块间大空隙——min-height 只留给 IPv6 行,IP 行 margin 归零
- 窄屏 .ip-row 一刀切 min-height:75px 使 IPv4 行(1 行 30px)下部空出 ~45px,
  与 style.css 窄屏 .one-line 的 0.5em margin 叠加成大段空白
- IPv6 行拆出 ip-row-v6 类单独吃 75px 预留(align-content:flex-start 防行间拉伸)
- 窄屏 .ip-row margin:0 盖掉 .one-line 的 0.5em(scoped 特异性更高)
- IPv4 回填前后均 1 行 30px,高度不变,不引入新 CLS
- CDP 实测(412 宽):两行空隙 61px → 0px,IP 回填正常
2026-10-05 09:58:18 +08:00
nomdn 19fe134762 perf(frontend-ssr): modulepreload 降级+探测提前+关 appManifest+链接色达标,LH desktop 100
- compress-html: modulepreload 注入 fetchpriority=low,render-blocking CSS 先行
  (mobile FCP 2345→1109ms,三档实测 low 最优:medium 仍挤 CSS 净亏)
- index.vue: 三个 IP 探测 fetch 从 onMounted 提前到 head 内联脚本(实测 392ms 发出,
  旧时序要等水合 ~2000ms),结果挂 window.__ipProbe + ip-probe 事件,onMounted 只消费
- nuxt.config: experimental.appManifest=false,不再请求 builds/meta/<id>.json,
  NUXT_E5002 从源头消失(本地「服务旧磁盘新」与线上发版瞬间均免疫)
- 链接色 --link-color 变量:亮 #1d7a4a(白底 5.34:1 过 axe 4.5 线),暗保持 #299764,
  style.css 六处硬编码色值收口
- LH 13.5:desktop 100/99/100(TBT 14-53ms),mobile 干净轮 92-96;A11y/BP 全程 100
2026-10-05 01:56:25 +08:00
nomdn 5445b4df64 revert(frontend-ssr): 撤回文档内容图片宽高注入
回退 243060a 中的图片治理部分:markdown.ts 移除 width/height 注入与
doc-image-dims.json 构建期清单,package.json/pnpm-lock 去 image-size 依赖。
2026-10-05 01:56:24 +08:00
nomdn 4f6cf7fac7 chore(frontend): 移除 frontend-svelte 前端目录
整目录删除(SvelteKit 3 + adapter-cloudflare)。注意:.github/workflows/frontend-svelte.yml
仍保留,其 paths 过滤 frontend-svelte/** 在目录删除后不再触发,属死文件,去留待定。
2026-10-05 01:55:57 +08:00
nomdn 243060a32a perf(frontend-ssr): 修文档页移动端 500,LH 拉升至 95/86/93,主题色改 #299764
一揽子水合修复 + 性能优化 + 品牌色更换:

- 修 P0:/doc/** 撤预渲染改回 SSR——预渲染 HTML 恒为宽屏,真机 UA 下
  客户端窄屏判定与之失配导致整页 500。窄屏判定收口到
  composables/useShellNarrow.ts(服务端按 sec-ch-ua-mobile/UA 判、客户端
  沿用序列化值、爬虫强制宽屏),移动端 /doc 恢复 200 且水合成本大降
- 图片治理:markdown 渲染注入 loading=lazy + decoding=async + 真实宽高;
  尺寸清单 config/doc-image-dims.json 由 nuxt.config 构建期扫 public/ 生成
  (CF Workers 无 fs,只能构建期算),unsized-images fail→pass
- 源站压缩:compressPublicAssets(142 个 .gz/.br)+ server/plugins/
  compress-html.ts(SSR HTML 按 Accept-Encoding 回 br/gzip,首页
  35KB→7.5KB),消除 Lantern 无压缩高估
- a11y:/dns 的 el-select 补 aria-label(92→97)
- NuxtLink 预取改 interaction(省 idle 期观察者任务,ssl TBT 370→120ms)
- 主题色全局 #3EAF7C → #299764(17 文件 38 处,白底对比 2.75→3.68:1)

LH 实证(mobile):/ 67→95、/dns 61→86、/ssl 57→93;桌面 / 100、/dns 99、
/ssl 98。零新增 fail;unsized-images、unused-css/js、interactive、
speed-index 等一批 fail→pass。剩余 fail:bf-cache(LH 内部错误)、
color-contrast(品牌绿 3.68:1,需 4.5:1,知情保留)、TBT(页面 chunk
求值+水合地板,再降需组件级懒水合,UX 有损暂缓)。
2026-10-04 15:49:16 +08:00
nomdn 924b9bcc36 style(frontend-svelte): 还原原版 Element Plus 观感,图标改用本地 EP 图标
把迁移到 SvelteKit 后走样的 UI 逐项改回 nipw.cn 的 element-plus 观感;所有数值
都以线上原版的 computed style 为准(下载 live CSS 用脚本遍历 document.styleSheets
逐条 trace 命中规则),不靠读源码猜。

- 字体:font-family 还原成 UA 默认(不再落到细体);补齐标题的 UA 外边距
- 顶栏:60px 高 + 1px 底线;项 padding 0 20px(文字起点 +30px)、14px、#303133
- 文档菜单:56px/50px 行高、20px/40px 逐层缩进、选中用 EP 蓝 #409eff、
  分组箭头 12px 且颜色继承标题
- /doc 几何对齐:body 外边距归零(.doc-route 门控,离开路由自动摘除)、
  正文容器 margin-top 40px → 正文首行 y=120、侧栏 260px 与原版完全一致
- 输入框 16px、按钮 font-weight 400、圆角统一 4px(不再是大圆角)
- 链接色:正文 #2f7d5a(深色 #3eaf7c);文档锚点用「非分层 + 更高特异性」
  压过 github-markdown-css 的 .markdown-body a
- 图标:新增本地 Element Plus 图标组件(几何取自 @element-plus/icons-vue@2.3.2),
  配 scripts/extract-element-plus-icons.mjs 生成;去掉 @lucide/svelte 依赖(同步 bun.lock)
- EP 变量(--el-*)收拢在 site.css 的 :root,与 element-plus 源码逐条对应

实证:/ssl 与 / 在 1440×900 下与原版逐像素一致;/doc 正文 y=120、侧栏与菜单项
(56px/50px、箭头 t=138 l=227 12px #303133)与原版完全一致;375×812 下单一汉堡、
无横向溢出。bun run check 0 error 0 warning。
2026-10-04 03:38:51 +08:00
nomdn 611caa30f6 feat(frontend): 新增 SvelteKit 3 前端 frontend-svelte,接管 ipw-cn Worker
把 frontend-ssr(Nuxt 4 全栈)整体迁移到 SvelteKit 3 + Svelte 5,包管理用 bun,
组件库 shadcn-svelte。18 条路由 + 5 个 server endpoint 全部跑通。

迁移内容
- 12 个工具页(location/asn/whois/ssl/ipv6webcheck/dns/dnssec/tcping/ipv6tcping/
  speedtest/ipv6speedtest/screenshot)+ 首页 + 页头页脚 + 文档站
- server 路由:/ip、/ip/[ip]、/api/markdown/[...path]、/api/whois/[domain]、
  /middleware/[...slug](内置中间件兜底转发,含 403/429 与路径穿越防护)
- lib 收口:node-pool(单成功重试 / 多节点并发)、visitor-ip、ui-classes、
  TcpingPanel/SpeedtestPanel(v4/v6 共用)、server/docs(36 篇 md 构建期内联)
- 部署:adapter-cloudflare + wrangler.jsonc;CI 新增 frontend-svelte.yml
  (bun + wrangler deploy --keep-vars),frontend-ssr.yml 去掉 push 触发

运行时实证中修掉的问题(构建期均不可见)
- TcpingPanel/SpeedtestPanel 用了 untrack 但未 import ⇒ 4 个页面 SSR 500
- middleware 路由的 API_KEYS/APIKEYS 是裸标识符 ⇒ 运行时 ReferenceError,
  改为从 $app/env/private 导入(Kit 3;$env/dynamic/private 已废弃)
- 补 /doc/** 的 SSG(对齐旧站 routeRules.prerender):32 个 html;
  DocMenu 折叠改用 hidden,否则预渲染爬虫抓不到子树
- app.html 内联脚本在预渲染页拿不到 CSP hash(Kit 只 hash 组件内联脚本),
  改为构建期现算 sha256 注入 script-src,避免深色模式首屏脚本被拦
- 修正 16 处历史遗留的文档目录锚点(旧站同样存在,Nitro 不校验锚点)

验证:bun run check 0 error;wrangler dev 下 18 条路由全 200;SSR 页 CSP 走响应头
+ nonce,SSG 页走 <meta> + hash 且两个内联脚本均被覆盖;middleware 的 Authorization
注入用本地 echo 上游实测通过;无 API_KEYS 时优雅降级。

另外把链接颜色改回品牌绿 #3eaf7c(frontend-ssr 同步)。
2026-10-04 00:44:24 +08:00
27 changed files with 642 additions and 177 deletions

No files matched your search

+9 -5
View File
@@ -1,11 +1,15 @@
name: Build and Deploy to Cloudflare Workers
name: (已停用自动部署) frontend-ssr → Cloudflare Workers
# 前端已迁移到 frontend-svelte(SvelteKit + shadcn-svelte,包管理器 bun),
# 自动部署改由 .github/workflows/frontend-svelte.yml 负责。
#
# 这里**刻意去掉 push 触发**:两个 workflow 都部署同名 Worker(wrangler.jsonc 里的 "ipw-cn"),
# 若继续按 push 触发,改 frontend-ssr 会把新前端的线上版本覆盖掉。
# 保留 workflow_dispatch 只是为了万一还需要临时用旧站顶一下(手动触发)。
# 新前端确认稳定后,这个文件可以直接删除。
on:
push:
branches: ['main']
paths:
- 'frontend-ssr/**'
workflow_dispatch:
jobs:
deploy:
runs-on: ubuntu-latest
+33
View File
@@ -0,0 +1,33 @@
name: Build and Deploy frontend-svelte to Cloudflare Workers
on:
push:
branches: ['main']
paths:
- 'frontend-svelte/**'
workflow_dispatch:
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
# 包管理器是 bun(见 frontend-svelte/bun.lock),不再用 pnpm/npm
- name: Setup bun
uses: oven-sh/setup-bun@v2
with:
bun-version: latest
# 部署到 Cloudflare Workers
# --keep-vars:保留 Worker 上已配置的环境变量(如 API_KEYS),别被本次部署清掉
- name: Deploy to Cloudflare Workers
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
run: |
cd frontend-svelte
bun install --frozen-lockfile
bun run build
bunx wrangler deploy --keep-vars
+33 -18
View File
@@ -4,7 +4,21 @@ import { useDark, useToggle } from '@vueuse/core';
import { Moon, Sunny, Expand } from '@element-plus/icons-vue';
import { config } from '../config/index';
const isNarrow = ref(false);
// ==================== 窄屏判定(水合前就必须定下来) ====================
// 为什么不能等到 onMounted 才算:SSR 不知道视口宽度,只能按宽屏输出 ——
// 于是整条桌面版顶栏菜单(10 个条目 + 分隔线 + 3 个下拉)会被下发给手机;
// 水合时 isNarrow 还是 false,手机就先把这一整套水合一遍
// (实测 375×812:header-menu 区域 268 次 DOM 变更、70 次事件绑定),
// 紧接着 onMounted 把它改成窄屏、再整棵销毁重建 —— 这一整轮纯属白干。
// 所以改成「服务端按请求头判定、客户端直接沿用服务端序列化下来的结论」,
// 两边输入一致 ⇒ 结论一致 ⇒ 水合期不产生任何分歧,桌面上仍是原来的形态。
// 权威值依旧由 onMounted 的 matchMedia 覆盖,管「桌面窗口被拖窄」这类请求头判不出的情形。
//
// 注意:**不要**在客户端重新嗅探 navigator.userAgent。SSR 页两边大概率巧合一致,
// 但预渲染页没有请求头(服务端恒判宽屏),客户端一嗅探就必然分叉 ——
// 实测那会让整个 /doc/** 在真手机上水合失配、Nuxt 直接渲染 500 页。
// 判定逻辑与文档见 app/composables/useShellNarrow.ts。
const isNarrow = useShellNarrow()
let mediaQueryList: MediaQueryList | null = null;
const drawer = ref(false);
@@ -138,13 +152,22 @@ onMounted(() => {
window.removeEventListener('resize', updateViewport);
});
if (!umamiScript && config.umamiScriptUrl) {
// 统计脚本延迟到浏览器空闲再注入:umami 源响应慢(实测 ~1.6s),
// 挂在 onMounted 里会和首页首屏的拨测请求抢慢速网络的带宽,
// 拖累 Speed Index;统计本身不需要抢首屏前的任何窗口。
const loadUmami = () => {
if (umamiScript || !config.umamiScriptUrl) return
umamiScript = document.createElement('script')
umamiScript.src = config.umamiScriptUrl
umamiScript.async = true
umamiScript.setAttribute('data-website-id', config.umamiWebsiteId)
document.head.appendChild(umamiScript)
}
if ('requestIdleCallback' in window) {
requestIdleCallback(loadUmami, { timeout: 4000 })
} else {
setTimeout(loadUmami, 2000)
}
})
</script>
@@ -345,7 +368,11 @@ onMounted(() => {
</style>
<style>
:root {
--el-color-primary: #3EAF7C;
--el-color-primary: #299764;
/* 正文/页脚链接色:亮色白底需 ≥4.5:1(axe color-contrast 审计线)。
#299764 白底只有 3.68:1,加深到 #1d7a4a = 5.34:1,留足余量。
主题色(按钮/高亮等 --el-color-primary 消费方)保持 #299764 不动,只分流链接。 */
--link-color: #1d7a4a;
}
html.dark {
/* 暗色模式下根元素的文字色与页面底色。
@@ -354,22 +381,10 @@ html.dark {
`html.dark[data-v-xxxx]` —— 整条规则永不匹配,底色只能靠浏览器的 color-scheme 兜底。 */
color: rgba(255, 255, 255, 0.87);
background-color: #242424;
--el-color-primary: #3EAF7C;
--el-color-primary: #299764;
/* 暗底 #242424 上 #299764 对比 ~4.2:1,且再加深只会更差 —— 暗色链接保持原色 */
--link-color: #299764;
}
/* 文字链接按模式拆色:亮色下 #3EAF7C 在白底只有 2.75:1、在引用块底 #f9f9f9 上 2.61:1
(axe 的 color-contrast,权重 7,是移动端 a11y 唯一的扣分项);换成同色系加深的
#2F7D5A 后分别是 5.00:1 / 4.75:1。暗色底上 #3EAF7C 本来就有 5.64:1 ⇒ 保留品牌色。
必须写在这个【非 scoped】块里:style.css 是被 <style scoped> @import 进来的,
根元素选择器会被加上 scope 属性而永不匹配(同上面底色那两条的原因)。
另外这三条要逐一列出而不是只写 `html.dark a`:footer .one-line a 的特异性是 (0,1,2),
比 `html.dark a` 的 (0,1,1) 高,不显式覆盖的话页脚链接在暗色下会留在深绿。 */
html.dark a,
html.dark blockquote a,
html.dark footer .one-line a,
html.dark .header-anchor {
color: #3EAF7C;
}
/* Drawer 内部链接占满一行。
注意用直接子选择器(> a):文档菜单(DocMenu)里的 <a> 嵌在 el-menu-item 里,
不能被这套「整行大按钮」规则吃掉,否则菜单项会变成上下带 1em 内边距的块。
@@ -0,0 +1,34 @@
/**
* 顶栏 / 抽屉「宽屏还是窄屏」的唯一判定入口(服务端算一次,客户端沿用)。
*
* 为什么客户端不自己嗅探 navigator.userAgent:
* SSR 出的 HTML 是跟着【服务端】的判定走的。客户端若各算各的,只要两边结论不一致,
* Vue 水合就会整棵树失配 —— 实测在预渲染的 /doc/** 上是直接抛
* `Cannot read properties of null (reading 'nodeType')`,然后被 Nuxt 渲染成 500 页
* (真手机打开文档站必然复现:预渲染时没有请求头 ⇒ 服务端恒判宽屏)。
* 把服务端的判定序列化进 payload 当客户端的初值,首帧 vdom 与 HTML 必然一致;
* 顺带还免疫「CDN 把桌面版缓存回给手机」——那种情况下客户端也会乖乖按 HTML 走宽屏。
*
* 权威值仍由 app.vue 的 onMounted 用 matchMedia 覆盖,管「桌面窗口被拖窄」这类
* 请求头判不出的情形。窄屏判定是全局共享状态(useState),文档页据此决定要不要
* 渲染侧栏,所以两边读的必须是同一个 ref。
*/
const CRAWLER_RE =
/bot|crawler|spider|slurp|bingbot|baiduspider|yandex|duckduckbot|facebookexternalhit|preview/i
export function uaIsNarrow(ua: string, chMobile?: string) {
if (!ua && !chMobile) return false // 两个都拿不到(含预渲染):按宽屏,与改造前一致
if (CRAWLER_RE.test(ua)) return false // 抓取端一律宽屏,否则顶栏导航链接会从 HTML 里消失
if (chMobile) return chMobile === '?1' // Chromium 的 Client Hint,最准
return /Android|iPhone|iPod|Windows Phone|webOS|BlackBerry|Opera Mini|IEMobile|Mobile/i.test(ua)
}
export function useShellNarrow() {
// 只有服务端拿得到请求头;客户端分支只为类型与「init 被意外调用」兜底。
const headers: Record<string, string | undefined> = import.meta.server
? useRequestHeaders(['user-agent', 'sec-ch-ua-mobile'])
: {}
return useState<boolean>('ipw-shell-narrow', () =>
uaIsNarrow(headers['user-agent'] || '', headers['sec-ch-ua-mobile']),
)
}
+2 -2
View File
@@ -9,10 +9,10 @@ html.dark .markdown-body {
/* GitHub 暗色主题的核心颜色 */
--color-fg-default: #e6edf3;
--color-fg-muted: #8b949e;
--color-fg-accent: #3EAF7C;
--color-fg-accent: #299764;
--color-bg-default: #242424;
--color-bg-muted: #080707;
--color-bg-accent: #3EAF7C;
--color-bg-accent: #299764;
--color-border-default: #242424;
--color-border-muted: #242424;
+2 -2
View File
@@ -261,10 +261,10 @@ function getStatusClass(status: string): string {
<style>
:root {
--el-color-primary: #3EAF7C;
--el-color-primary: #299764;
}
html.dark {
--el-color-primary: #3EAF7C;
--el-color-primary: #299764;
}
.el-icon{
font-size: 1.3em;
+5 -4
View File
@@ -201,7 +201,8 @@ const doc = page.value;
v-model="tmpDomain"
placeholder="请输入域名(如:example.com)"
/>
<el-select v-model="recordType" style="width: 150px;" class="custom-height-select">
<!-- aria-label 落到 EP select 内部的原生 input 上,否则 Lighthouse 的 label 审计必挂 -->
<el-select v-model="recordType" style="width: 150px;" class="custom-height-select" aria-label="DNS 记录类型">
<el-option
v-for="item in recordTypes"
:key="item.value"
@@ -297,7 +298,7 @@ const doc = page.value;
}
.markdown :deep(a) {
color: #3EAF7C !important;
color: #299764 !important;
font-size: 1.3em;
text-decoration: none
}
@@ -305,11 +306,11 @@ const doc = page.value;
<style>
:root {
--el-color-primary: #3EAF7C;
--el-color-primary: #299764;
}
html.dark {
--el-color-primary: #3EAF7C;
--el-color-primary: #299764;
}
.el-icon {
+2 -2
View File
@@ -284,10 +284,10 @@ async function getUserIP() {
<style>
:root {
--el-color-primary: #3EAF7C;
--el-color-primary: #299764;
}
html.dark {
--el-color-primary: #3EAF7C;
--el-color-primary: #299764;
}
.el-icon{
font-size: 1.3em;
+5 -1
View File
@@ -1,6 +1,10 @@
<script setup lang="ts">
import { getDocMeta } from '../../../config/doc'
// 窄屏下这条侧栏被 CSS 隐藏(导航在顶栏抽屉里),但照样要花钱水合 ——
// 详见 app/pages/doc/index.vue 的同名注释。
const isNarrow = useShellNarrow()
const route = useRoute();
const path = computed(() => route.path)
@@ -18,7 +22,7 @@ useHead({
<div class="box">
<!-- 文档导航只有一份实现(app/components/DocMenu.vue)。
宽屏:就是这条左侧固定侧栏;窄屏:本侧栏隐藏,改由顶部抽屉菜单承载 -->
<DocMenu class="sidebar-menu" />
<DocMenu v-if="!isNarrow" class="sidebar-menu" />
<div class="content">
<div class="markdown-body" v-if="page">
<div v-html="page" />
+8 -1
View File
@@ -1,6 +1,13 @@
<script setup lang="ts">
import { getDocMeta } from '../../../config/doc'
// 窄屏(≤768px)下这条侧栏被 CSS 整个隐藏,导航改由顶栏抽屉承载。
// 但「只是藏起来」不等于不花钱:Vue 依旧会把整棵 40 项的 el-menu 水合一遍
// (实测手机端 doc-sidebar 区域 53 次事件绑定)。所以直接按判定结果不渲染。
// 这里用的是全局共享的窄屏状态(服务端按请求头判定并随 payload 下发),
// 与 app.vue 同一份 —— 首帧与服务端 HTML 一致,不会有水合分歧。
const isNarrow = useShellNarrow()
const { data: page } = await useAsyncData('doc-index', () =>
$fetch('/api/markdown/doc/index')
)
@@ -18,7 +25,7 @@ useHead({
<div class="box">
<!-- 文档导航只有一份实现(app/components/DocMenu.vue)。
宽屏:就是这条左侧固定侧栏;窄屏:本侧栏隐藏,改由顶部抽屉菜单承载 -->
<DocMenu class="sidebar-menu" />
<DocMenu v-if="!isNarrow" class="sidebar-menu" />
<div class="content">
<div class="markdown-body" v-html="doc"></div>
</div>
+113 -32
View File
@@ -24,6 +24,32 @@ useHead({
{ name: 'twitter:card', content: 'summary_large_image' },
],
script: [
{
// —— IP 探测提前到 HTML 解析期(LCP 优化)——
// 原来三个探测请求在 onMounted(水合完成后)才发出,模拟链路是
// 「水合 → 发请求 → 等服务端 → 回填渲染」,IPv6 行作为 LCP 元素被整段串行拖住
// (mobile 模拟 LCP 2275ms,满分线约 1600ms)。
// 这里在 head 里用内联脚本立刻发起,与 render-blocking CSS 的下载、
// hydration 三路并行;结果挂 window.__ipProbe,完成时派发 ip-probe 事件,
// onMounted 只负责消费(见下方),不再等水合才起步。
// 用原生 fetch:此刻 ofetch 还没加载;不带 retry 的语义与旧版 retry:false 一致
// (底层 fetch 本来就不会重试)。
innerHTML: [
'window.__ipProbe = {};',
'(function () {',
` function emit(key) {`,
` return function (text) {`,
` window.__ipProbe[key] = text;`,
` document.dispatchEvent(new CustomEvent('ip-probe', { detail: key }));`,
` };`,
` }`,
// 与 onMounted 的语义一致:三接口各自独立、失败静默(保持「查询中」占位)
` fetch(${JSON.stringify(config.v6OnlyAPI)}).then(function (r) { return r.text(); }).then(emit('v6')).catch(function () {});`,
` fetch(${JSON.stringify(config.v4OnlyAPI)}).then(function (r) { return r.text(); }).then(emit('v4')).catch(function () {});`,
` fetch(${JSON.stringify(config.DualStackAPI)}).then(function (r) { return r.text(); }).then(emit('dual')).catch(function () {});`,
'})();',
].join('\n'),
},
{
type: 'application/ld+json',
innerHTML: JSON.stringify({
@@ -102,35 +128,63 @@ const yourIPv4 = ref('');
const yourIPv6 = ref('');
onMounted(() => {
// (代码高亮已移到 setup 顶部的 useAsyncData,随 SSR 一起产出,这里只剩三个 IP 查询。)
// (代码高亮已移到 setup 顶部的 useAsyncData,随 SSR 一起产出。)
// 三个地址各自独立请求、各自渲染:谁先回来谁先上屏。
// 不用 Promise.allSettled 包起来等齐——那样只要有一个慢(例如纯 IPv4 网络下 v6 接口要等超时),
// 已经拿到的 IPv4 与双栈结果也得一起干等。
// —— 三个 IP 探测:请求已由 head 内联脚本提前发出(见 useHead),这里只消费 ——
// 内联脚本把结果挂 window.__ipProbe[key] 并派发 ip-probe 事件。三种时序都要覆盖:
// ① 值已就位(事件在 onMounted 之前派发过)→ 同步读 store 直接上屏;
// ② 值未到 → 挂监听等事件(收到匹配 key 才移除,不能 once——
// 三个 key 共用同一事件名,once 会把别人的事件当自己的吃掉);
// ③ window.__ipProbe 不存在(内联脚本被 CSP 拦掉/异常)→ 退回自己发请求,行为同旧版。
//
// retry: false —— ofetch 默认会对 GET 重发一次:网络层失败时它拿不到 response,就按 500 计
// (500 在默认重试白名单里),且默认延迟为 0,立即重发。于是"这个节点连不上"会被原样重做一遍,
// 用户白等一倍时间(实测 ofetch 1.5.1:裸调用触发 2 次真实 fetch,传 retry:false 后只剩 1 次;
// 浏览器自身不会重试,底层 fetch 对同一请求只被调用过一次)。
// 浏览器自身不会重试,底层 fetch 对同一请求只被调用过一次)。仅 ③ 的兜底路径会走到 $fetch。
const ipFetchOptions = { retry: false } as const;
const probeStore = (window as any).__ipProbe as Record<string, string> | undefined;
$fetch<string>(config.DualStackAPI, ipFetchOptions)
.then((ip) => {
ipAddress.value = ip;
})
.catch(() => {
// 单个接口失败不影响其它两行展示,保持"查询中"占位
});
$fetch<string>(config.v4OnlyAPI, ipFetchOptions)
.then((ip) => {
yourIPv4.value = ip;
})
.catch(() => {});
$fetch<string>(config.v6OnlyAPI, ipFetchOptions)
.then((ip) => {
yourIPv6.value = ip;
})
.catch(() => {});
const consumeProbe = (key: string, apply: (ip: string) => void): boolean => {
if (!probeStore) return false; // ③ 内联脚本没跑起来
if (typeof probeStore[key] === 'string') {
apply(probeStore[key]); // ① 值已就位
return true;
}
const onProbe = (e: Event) => {
if ((e as CustomEvent).detail !== key) return;
document.removeEventListener('ip-probe', onProbe);
apply(probeStore[key]); // ② 事件到达
};
document.addEventListener('ip-probe', onProbe);
return true;
};
// 三个地址各自独立请求、各自渲染:谁先回来谁先上屏。
// 不用 Promise.allSettled 包起来等齐——那样只要有一个慢(例如纯 IPv4 网络下 v6 接口要等超时),
// 已经拿到的 IPv4 与双栈结果也得一起干等。
if (!consumeProbe('dual', (ip) => { ipAddress.value = ip; })) {
$fetch<string>(config.DualStackAPI, ipFetchOptions)
.then((ip) => {
ipAddress.value = ip;
})
.catch(() => {
// 单个接口失败不影响其它两行展示,保持"查询中"占位
});
}
if (!consumeProbe('v4', (ip) => { yourIPv4.value = ip; })) {
$fetch<string>(config.v4OnlyAPI, ipFetchOptions)
.then((ip) => {
yourIPv4.value = ip;
})
.catch(() => {});
}
if (!consumeProbe('v6', (ip) => { yourIPv6.value = ip; })) {
$fetch<string>(config.v6OnlyAPI, ipFetchOptions)
.then((ip) => {
yourIPv6.value = ip;
})
.catch(() => {});
}
});
</script>
@@ -143,10 +197,10 @@ onMounted(() => {
</header>
</div>
<div class="content">
<div class="one-line">
<div class="one-line ip-row">
<b>IPv4</b>&nbsp<p>{{ yourIPv4 }} </p>&nbsp<RouterLink :to="`/location?ip=${yourIPv4}`" target="_blank">查询归属地</RouterLink>
</div>
<div class="one-line">
<div class="one-line ip-row ip-row-v6">
<b>IPv6</b>&nbsp<p v-if="yourIPv6">{{ yourIPv6 }}</p><RouterLink :to="`/location?ip=${yourIPv6}`" target="_blank" v-if="yourIPv6">&nbsp查询归属地</RouterLink><RouterLink v-else to="/doc/user/enable_ipv6" target="_blank">没有IPv6地址,查看如何开启IPv6</RouterLink>
</div>
<div class="ip-priority">
@@ -180,6 +234,20 @@ onMounted(() => {
margin: 1em 0;
}
/* 两行 IP 记录预留确定高度,消除 CLS。
实测(CDP 逐帧采样 + CSS 变体对照,填值后量终态几何):
这两个 div 的文本是 onMounted 之后才由 $fetch 填上的。IPv6 地址(39 字符)
在窄屏下把 .one-line 从 1 行撑到 3 行,下面 .ip-priority / 代码块 / blockquote /
footer 被整体顶下 50px —— Lighthouse 记的 `div.code-block` 那条 CLS 0.0359
(占全页 88%)就是这么来的,而满分门槛只有 0.0396,差 3% 就被扣分。
修法是让高度与「填完之后」一致。终态高度按视口分档(CDP 实测,不是估算):
移动 412 宽 → ip2 终态 75px(3 行);桌面 1350 宽 → 30px(1 行,.content 宽 60%)
所以必须分媒体查询给值:一刀切 75px 会让桌面凭空多出 45px 空白。
代价是接口 pending 时这里空着,但换来 CLS 归零。 */
.ip-row {
min-height: 30px;
}
.code-block {
margin-top: 1rem;
padding: 1rem;
@@ -198,17 +266,30 @@ onMounted(() => {
}
@media (max-width: 768px) {
.ip-priority h2 {
font-size: 1.1em;
}
.code-block {
padding: 0.75rem;
font-size: 0.8em;
}
.ip-priority h2 {
font-size: 1.1em;
}
.code-block {
padding: 0.75rem;
font-size: 0.8em;
}
/* 窄屏下 IPv6 地址换 3 行(CDP 实测 412 宽终态 h=75px),见上面 .ip-row 的注释。
75px 只给 IPv6 行:IPv4 地址窄屏仍是 1 行(~30px),一刀切 75px 会让
IPv4 行下部空出 ~45px,两块 IP 之间出现一大段空白。
IPv4 回填前后都是 1 行 30px,高度不变 → 不引入新的 CLS。 */
.ip-row {
/* style.css 窄屏给 .one-line 加了 0.5em 上下 margin(两块之间叠 16px),
IP 行的间距由行高本身决定,这里归零。scoped 属性选择器特异性更高,稳赢。 */
margin: 0;
}
.ip-row-v6 {
min-height: 75px;
align-content: flex-start;
}
}
</style>
<style>
:root {
--el-color-primary: #3EAF7C;
--el-color-primary: #299764;
}
</style>
+2 -2
View File
@@ -272,10 +272,10 @@ onMounted(() => {
<style>
:root {
--el-color-primary: #3EAF7C;
--el-color-primary: #299764;
}
html.dark {
--el-color-primary: #3EAF7C;
--el-color-primary: #299764;
}
.el-icon{
font-size: 1.3em;
+2 -2
View File
@@ -287,11 +287,11 @@ onMounted(() => {
<style>
:root {
--el-color-primary: #3EAF7C;
--el-color-primary: #299764;
}
html.dark {
--el-color-primary: #3EAF7C;
--el-color-primary: #299764;
}
.el-icon {
+2 -2
View File
@@ -305,10 +305,10 @@ onMounted(() => {
<style>
:root {
--el-color-primary: #3EAF7C;
--el-color-primary: #299764;
}
html.dark {
--el-color-primary: #3EAF7C;
--el-color-primary: #299764;
}
.el-icon{
font-size: 1.3em;
+2 -2
View File
@@ -261,7 +261,7 @@ onMounted(async () => {
<style>
@import "../style.css";
:root {
--el-color-primary: #3EAF7C;
--el-color-primary: #299764;
}
.el-icon{
font-size: 1.3em;
@@ -444,7 +444,7 @@ html.dark pre code {
.badge-item h4 {
margin: 0 0 15px 0;
color: #3EAF7C;
color: #299764;
font-size: 1.2em;
}
+1 -1
View File
@@ -286,7 +286,7 @@ html.dark .loading-overlay p {
}
.markdown :deep(a) {
color: #3EAF7C !important;
color: #299764 !important;
font-size: 1.3em;
text-decoration: none
}
+2 -2
View File
@@ -272,10 +272,10 @@ onMounted(() => {
<style>
:root {
--el-color-primary: #3EAF7C;
--el-color-primary: #299764;
}
html.dark {
--el-color-primary: #3EAF7C;
--el-color-primary: #299764;
}
.el-icon{
font-size: 1.3em;
+1 -1
View File
@@ -389,7 +389,7 @@ onMounted(() => {
<style>
:root {
--el-color-primary: #3EAF7C;
--el-color-primary: #299764;
}
.el-icon{
font-size: 1.3em;
+2 -2
View File
@@ -271,11 +271,11 @@ onMounted(() => {
<style>
:root {
--el-color-primary: #3EAF7C;
--el-color-primary: #299764;
}
html.dark {
--el-color-primary: #3EAF7C;
--el-color-primary: #299764;
}
.el-icon {
+3 -3
View File
@@ -281,7 +281,7 @@ function getStatusClass(status: string): string {
@import "../style.css";
@import "../../assets/css/tool-common.css";
.markdown :deep(a) {
color: #3EAF7C !important;
color: #299764 !important;
font-size: 1.3em;
text-decoration: none
}
@@ -319,10 +319,10 @@ function getStatusClass(status: string): string {
<style>
:root {
--el-color-primary: #3EAF7C;
--el-color-primary: #299764;
}
html.dark {
--el-color-primary: #3EAF7C;
--el-color-primary: #299764;
}
.el-icon{
font-size: 1.3em;
+7 -7
View File
@@ -109,14 +109,14 @@ blockquote p {
margin: 0.5em;
}
blockquote a{
color: #2F7D5A;
color: var(--link-color);
font-size: 1em;
/* 正文里的链接仅靠颜色区分不够(绿 #2F7D5A 与周围正文的对比度远低于 3:1),
/* 正文里的链接仅靠颜色区分不够(绿 #299764 与周围正文的对比度远低于 3:1),
补下划线提供「非颜色」的视觉区分(link-in-text-block) */
text-decoration: underline
}
a {
color: #2F7D5A;
color: var(--link-color);
font-size: 1.3em;
text-decoration: none
@@ -132,7 +132,7 @@ footer .one-line {
footer .one-line a {
font-size: 1em;
text-align: center;
color: #2F7D5A;
color: var(--link-color);
text-decoration: none
}
footer {
@@ -207,7 +207,7 @@ footer {
}
blockquote a{
color: #2F7D5A;
color: var(--link-color);
font-size: 1em;
/* 与上面的非窄屏规则保持一致:窄屏同样靠下划线做非颜色区分。
此处若仍写 none,因其位置在后、特异性相同,会把桌面的 underline 盖掉。 */
@@ -272,7 +272,7 @@ h4>.header-anchor,
h5>.header-anchor,
h6>.header-anchor {
opacity: 1;
color: #2F7D5A;
color: var(--link-color);
}
h1:hover>.header-anchor,
@@ -285,5 +285,5 @@ h6:hover>.header-anchor {
}
.header-anchor::before {
content: "#";
color: #2F7D5A;
color: var(--link-color);
}
+1 -1
View File
@@ -226,7 +226,7 @@ html.dark pre code {
.badge-item h4 {
margin: 0 0 15px 0;
color: #3EAF7C;
color: #299764;
font-size: 1.2em;
}
+43 -14
View File
@@ -1,5 +1,4 @@
import {config} from "./config/index";
import { docConfig } from "./config/doc";
// https://nuxt.com/docs/api/configuration/nuxt-config
const extractDomains = (obj: any): string[] => {
// 将对象转为 JSON 字符串,用正则匹配所有 https:// 开头的域名部分
@@ -27,6 +26,25 @@ const middlewareOrigin = config.Middleware?.[0]
export default defineNuxtConfig({
compatibilityDate: '2025-07-15',
devtools: { enabled: true },
experimental: {
// 关闭 app manifest 版本检查:水合后 Nuxt 会在 requestIdleCallback 里请求
// /_nuxt/builds/meta/<buildId>.json,buildId 对不上(本地「服务旧、磁盘新」、
// 或线上发版切换瞬间)就抛 NUXT_E5002 污染 console,还拖累 Best Practices 分。
// 本项目没依赖 appManifest 的客户端能力(无客户端 routeRules、无 prerender
// 判定),关掉少一个请求,console 永远干净。
appManifest: false,
// 注:inlineStyles: true 实测是负优化——它只内联 page 组件样式(+12KB HTML),
// entry/EP 等共享样式仍是 render-blocking 外链,关键请求一个没省。勿开。
defaults: {
nuxtLink: {
// NuxtLink 默认「链接进入视口就预取目标页 JS」——顶栏 15 个菜单项会让
// 首屏把其它工具页的 chunk(含 87KB 的校验库等重依赖)全拉下来,
// 白吃带宽还挤占首屏关键请求。改为「悬停/聚焦才预取」:真正要去
// 哪个页面时再拉那一页,首屏只加载当前页需要的代码。
prefetchOn: { interaction: true, visibility: false },
},
},
},
modules: [
"nitro-cloudflare-dev",
'@element-plus/nuxt',
@@ -53,8 +71,12 @@ export default defineNuxtConfig({
// SSR 期算完写进 HTML,客户端根本不会执行它,纯属白下。
// 把 manifest 上的标记抹掉即可:动态 chunk 回到「真正 import 时才加载」。
// 静态闭包走的是 preload 分支(modulepreload),不受影响。
// —— 2026-10-04 曾试把 preload 也置 false:FCP 确实 2333→1404(少了 22 个请求的
// 下载竞争),但 LCP 2387→2933、TBT 86→280(权重 25%/30% 远大于 FCP 的 10%),
// 总分 94→89 净亏。原因:这 22 个 modulepreload 是 entry 的静态依赖闭包,全掐掉后
// hydration 推迟、长任务落进 LCP/TBT 窗口。故只掐 prefetch,preload 必须留。
'build:manifest': (manifest) => {
for (const chunk of Object.values(manifest) as Array<Record<string, unknown>>) {
for (const chunk of Object.values(manifest) as unknown as Array<Record<string, unknown>>) {
if (chunk.prefetch) chunk.prefetch = false
}
},
@@ -101,9 +123,9 @@ export default defineNuxtConfig({
rel: 'preconnect',
href,
crossorigin: 'anonymous',
})),
} as const)),
...(middlewareOrigin
? [{ rel: 'dns-prefetch', href: middlewareOrigin }]
? [{ rel: 'dns-prefetch', href: middlewareOrigin } as const]
: []),
]
}
@@ -122,10 +144,13 @@ export default defineNuxtConfig({
},
routeRules: {
// 缓存头一律不在这里设,交由 CDN(EdgeOne)侧控制。
// /doc 文档站改为 SSG:构建时预渲染为静态 HTML,由边缘静态资源直接响应
'/doc/**': { prerender: true },
// /doc/** 曾经是 SSG(prerender),已撤回改为 SSR,原因见下方 nitro.prerender 处的注释。
},
nitro: {
// 构建期给 public/ 静态资源生成 .gz/.br 预压缩副本,按 Accept-Encoding 直接回;
// SSR HTML 的压缩在 server/plugins/compress-html.ts。两者合起来让本地直连的
// 传输体积与线上(EdgeOne 会压)一致,Lighthouse 的 Lantern 模拟才不会虚高。
compressPublicAssets: true,
publicAssets: [
{
dir: 'public',
@@ -137,17 +162,21 @@ export default defineNuxtConfig({
target: 'es2022' // 明确告诉 Nitro 使用 es2022 进行打包
}
},
prerender: {
// 显式列出所有 doc 路由(来自 config/doc.ts 的 docConfig 键),
// 让动态 [...slug] 页面在构建时全部预渲染为静态 HTML
routes: ['/doc', ...Object.keys(docConfig).filter((p) => p !== '/doc')],
},
// 这里曾用 prerender.routes 把整份 docConfig 的文档页在构建期预渲染成静态 HTML。
// 已撤回,改回 SSR —— 因为预渲染与「按请求头决定渲染形态」根本冲突:
// 构建期没有请求头,窄屏判定恒为宽屏,于是预渲染产物里永远躺着
// 「15 条桌面顶栏菜单 + 被 CSS 隐藏的 40 项文档侧栏」;真手机打开时客户端判定为窄屏,
// 首帧 vdom 与 HTML 对不上,Vue 水合直接抛
// `Cannot read properties of null (reading 'nodeType')`,整页变成 500。
// 顺带收益:/doc 现在也走运行时安全头(预渲染页拿不到 CSP,见下方 security.ssg)。
// 代价只是源站多跑一层 SSR,前面有 EdgeOne 缓存兜着。
},
security: {
ssg: {
// /doc 预渲染页的 CSP 会因 SRI modulepreload 哈希全量拼入而单行超 2000 字符,
// 触发 Cloudflare _headers 限制;关闭静态页安全头写入 _headers。
// SSR 页面仍由运行时中间件下发完整安全头,不受影响。
// 预渲染页的 CSP 会因 SRI modulepreload 哈希全量拼入而单行超 2000 字符,
// 触发 Cloudflare _headers 限制,故关掉静态页的安全头写入。
// 当前没有预渲染路由(/doc/** 已改回 SSR),这条是留给将来再开 prerender 时的开关:
// 一旦重新启用 prerender,那些页就会退回「无 CSP」状态。
nitroHeaders: false,
},
headers: {
@@ -0,0 +1,76 @@
import { brotliCompressSync, gzipSync, constants } from 'node:zlib'
import type { NitroApp } from 'nitro/types'
/**
* SSR HTML 压缩。
*
* ## 为什么要压
*
* nitro 不压 SSR 响应体(静态资源另有 compressPublicAssets 预压缩),
* 本地直连 / Lighthouse 测到的 HTML 就是裸传的。生产链路前面有 EdgeOne 兜底,
* 但源站自己压是标准做法,也让本地测量贴近线上。
*
* 只压 SSR 渲染出的 text/html:body 是字符串、体积 > 1KB、对方 Accept-Encoding
* 支持才压,按客户端优先级选 br(brotli)否则 gzip。
*
* ## 为什么必须显式指定 brotli quality = 4(别删这行)
*
* `brotliCompressSync` 不带 params 时默认 **quality 11**,那是给「离线压一次、
* 反复复用」准备的(压一次存盘,读一万次)。SSR 是**每请求现压**:
*
* quality 33KB HTML 68KB HTML
* 11 123ms / 7.2KB 169ms / 11.0KB
* 6 4ms / 7.8KB 5ms / 12.2KB
* 4 2ms / 8.6KB 3ms / 13.5KB
*
* Node 是单线程,这段同步压缩**全程阻塞事件循环,直接进 TTFB**。
* 首页 TTFB 实测:q11 时 225~260ms,改 q4 后 55~105ms —— 差的就是这 120~170ms。
* q4 只多 2.5KB,在 1.6Mbps 上是 12ms,换回 166ms,净赚。
*
* 教训:凡「每请求现压」的同步压缩都要显式指定质量,别吃库默认值。
*/
export default defineNitroPlugin((nitroApp: NitroApp) => {
nitroApp.hooks.hook('render:response', (response, { event }) => {
const headers = (response.headers ??= {})
const type = String(
headers['content-type'] ?? event.node.res.getHeader('content-type') ?? '',
)
if (!type.includes('text/html')) return
if (typeof response.body !== 'string' || response.body.length < 1024) return
if (headers['content-encoding']) return // 已有编码(异常路径),不叠加
// —— modulepreload 降优先级(2026-10-04 实测,mobile LH 三档对比后选定 low)——
// entry 的 26 个静态依赖 chunk 默认 High 优先级,和 4 个 render-blocking CSS 一起
// 排队(本地 H1 只有 6 个连接),CSS 被挤到后面 ⇒ Lantern 模拟 FCP 2345ms。
// 加 fetchpriority="low" 后 CSS 先行:FCP 2345→1109(-1236ms,首次过 1800 满分线),
// desktop 三轮全 100。代价是 JS 下载推迟 → hydration 落到 FCP 之后,TBT 86→213。
// 三档实测(mobile perf):无 fp 94/95,medium 92/93(medium 仍挤 CSS,FCP 2253,
// 收益尽失),low 94/95 —— low 总分最优。
// TBT 的 213 是这项改动的全部代价:FCP 提前后,原本在 FCP 前不计账的 hydration
// 长任务进了 TBT 窗口(FCP 每提前 ~500ms,TBT 约 +55,两者在当前架构下互斥)。
if (response.body.includes('rel="modulepreload"')) {
response.body = response.body.replaceAll(
'rel="modulepreload"',
'rel="modulepreload" fetchpriority="low"',
)
}
const accept = String(event.node.req.headers['accept-encoding'] ?? '')
let encoding: 'br' | 'gzip'
if (/\bbr\b/.test(accept)) {
response.body = brotliCompressSync(Buffer.from(response.body), {
params: { [constants.BROTLI_PARAM_QUALITY]: 4 },
})
encoding = 'br'
} else if (/\bgzip\b/.test(accept)) {
response.body = gzipSync(Buffer.from(response.body))
encoding = 'gzip'
} else {
return
}
headers['content-encoding'] = encoding
// 体积变了,content-length 必须跟着改,否则连接会被截断/挂起
headers['content-length'] = String((response.body as Buffer).length)
})
})
+76 -16
View File
@@ -649,6 +649,78 @@ func locateUserIP(c fiber.Ctx) error {
return c.Status(http.StatusOK).JSON(ipdb.SearchIP(ip))
}
// normalizeASN 把各数据源的 ASN 字符串统一成 "AS"+数字。
// 数据源格式不一:maxmind/dbip 的 MMDBASNResult.ASN 自带 "AS" 前缀(searchMMDBASN 拼的),
// ip2location 的 asn 是裸数字。含非数字字符时返回空串(视为无效)。
func normalizeASN(s string) string {
s = strings.TrimSpace(strings.ToUpper(s))
s = strings.TrimSpace(strings.TrimPrefix(s, "AS"))
if s == "" {
return ""
}
for _, r := range s {
if r < '0' || r > '9' {
return ""
}
}
return "AS" + s
}
// resolveASN 从 SearchIP 结果中按优先级提取可用 ASN(maxmind → dbip → ip2location),返回 "ASxxxx"。
//
// 之所以要兜底:此前 whois 段只认 maxmind_asn,某个 IP 一旦未命中该库,整段 ASN WHOIS 就凭空消失,
// 哪怕 dbip_asn / ip2location_asn 明明有数据。
func resolveASN(result map[string]interface{}) string {
for _, key := range []string{"maxmind_asn", "dbip_asn"} {
if r, ok := result[key].(*ipdb.MMDBASNResult); ok {
if asn := normalizeASN(r.ASN); asn != "" {
return asn
}
}
}
if m, ok := result["ip2location_asn"].(map[string]string); ok {
if asn := normalizeASN(m["asn"]); asn != "" {
return asn
}
}
return ""
}
// asnWhoisFailed 判定 ASN WHOIS 结果是否算失败,决定缓存 TTL(失败 30s / 成功 5min)。
// 注意 likexian/whois 出错时也返回 (result, nil),错误塞在 result.Error 里,不能只看 err 参数。
func asnWhoisFailed(r *webtest.ASNWhoisResult) bool {
return r == nil || r.Error != "" || (r.ASName == "" && r.OrgName == "")
}
// lookupASNWhois 带缓存的 ASN WHOIS 查询,handler 与 ws 两条路径共用。
// 缓存键取归一化后的 ASN("AS13335"):历史上是 "AS"+已带前缀的 ASN,实际拼成了 "ASAS13335"。
func lookupASNWhois(asn string) (*webtest.ASNWhoisResult, bool) {
asn = normalizeASN(asn)
if asn == "" {
return nil, false
}
if cached, ok := asnWhoisCache.Load(asn); ok {
if entry, ok := cached.(asnWhoisCacheEntry); ok && entry.result != nil {
if time.Since(entry.timestamp) < cacheTTL(entry.failed) {
return entry.result, true
}
}
asnWhoisCache.Delete(asn)
}
whoisData, err := webtest.QueryASNWhois(asn)
if err != nil {
return nil, false
}
asnWhoisCache.Store(asn, asnWhoisCacheEntry{
result: whoisData,
timestamp: time.Now(),
failed: asnWhoisFailed(whoisData),
})
return whoisData, true
}
func asnLookupHandler(c fiber.Ctx) error {
ip := c.Params("ip")
if ip == "" {
@@ -696,22 +768,10 @@ func asnLookupHandler(c fiber.Ctx) error {
}
}
// 使用 WHOIS 对 ASN 进行进一步解析
if maxmindASN, ok := result["maxmind_asn"].(*ipdb.MMDBASNResult); ok {
asnKey := "AS" + maxmindASN.ASN
if cached, ok := asnWhoisCache.Load(asnKey); ok {
entry := cached.(asnWhoisCacheEntry)
if time.Since(entry.timestamp) < cacheTTL(entry.failed) {
asnResult["whois"] = entry.result
} else {
asnWhoisCache.Delete(asnKey)
}
} else {
whoisData, err := webtest.QueryASNWhois(maxmindASN.ASN)
if err == nil {
asnResult["whois"] = whoisData
asnWhoisCache.Store(asnKey, asnWhoisCacheEntry{result: whoisData, timestamp: time.Now()})
}
// 使用 WHOIS 对 ASN 进行进一步解析(maxmind 未命中时回落到 dbip / ip2location)
if asn := resolveASN(result); asn != "" {
if whoisData, ok := lookupASNWhois(asn); ok {
asnResult["whois"] = whoisData
}
}
+173 -52
View File
@@ -614,63 +614,184 @@ type ASNWhoisResult struct {
Error string `json:"error"`
}
// asnFieldPatterns 用于从原始 ASN WHOIS 文本中提取字段
var asnFieldPatterns = map[string]*regexp.Regexp{
"asNumber": regexp.MustCompile(`(?i)^\s*ASNumber\s*[:=]\s*(.+?)\s*$`),
"asName": regexp.MustCompile(`(?i)^\s*ASName\s*[:=]\s*(.+?)\s*$`),
"asHandle": regexp.MustCompile(`(?i)^\s*ASHandle\s*[:=]\s*(.+?)\s*$`),
"regDate": regexp.MustCompile(`(?i)^\s*RegDate\s*[:=]\s*(.+?)\s*$`),
"updated": regexp.MustCompile(`(?i)^\s*Updated\s*[:=]\s*(.+?)\s*$`),
"orgName": regexp.MustCompile(`(?i)^\s*OrgName\s*[:=]\s*(.+?)\s*$`),
"orgId": regexp.MustCompile(`(?i)^\s*OrgId\s*[:=]\s*(.+?)\s*$`),
"country": regexp.MustCompile(`(?i)^\s*Country\s*[:=]\s*([A-Z]{2})\s*$`),
"abuseName": regexp.MustCompile(`(?i)^\s*OrgAbuseName\s*[:=]\s*(.+?)\s*$`),
"abuseEmail": regexp.MustCompile(`(?i)^\s*OrgAbuseEmail\s*[:=]\s*(.+?)\s*$`),
"abusePhone": regexp.MustCompile(`(?i)^\s*OrgAbusePhone\s*[:=]\s*(.+?)\s*$`),
// ==================== ASN WHOIS 多 RIR 解析 ====================
//
// QueryASNWhois 走 likexian/whois 的默认链路:先问 IANA(IANA 按 ASN 给出归属 RIR),
// 再取该 RIR 的记录;若 ARIN 只回了「转介桩」,库会再跟一跳取到真实记录。
// 因此 raw 里可能同时存在两段内容,且字段名随 RIR 而异:
//
// ARIN : ASNumber / ASName / OrgName / OrgId / Country / RegDate / Updated / OrgAbuse*
// APNIC : aut-num / as-name / descr / country / org / last-modified(无 org-name 时组织名在 descr)
// RIPE : aut-num / as-name / org / org-name / country / created / last-modified
// LACNIC : aut-num / owner / ownerid / country / created / changed
//
// 改造前只认 ARIN 字段 ⇒ 除 ARIN 注册的 ASN(Cloudflare AS13335、Google AS15169 等)外,
// 其余 RIR 的 ASN 全部解析为空(如中国电信 AS4134 只拿到 ARIN 转介桩的 "APNIC-4134")。
//
// 现在的规则:raw 里**存在 RIR 原生 aut-num 记录时一律以它为准,并整体忽略 ARIN 段**
// (ARIN 对非 ARIN 的 ASN 只回桩,其 ASName/OrgName/OrgAbuseEmail 都是误导信息);
// 没有原生记录时才按 ARIN 格式解析。
var (
// 原生(APNIC/RIPE/LACNIC/AFRINIC)字段
reASNAutNum = regexp.MustCompile(`(?im)^\s*aut-num:\s*AS?(\d+)\s*$`)
reASNAsName = regexp.MustCompile(`(?im)^\s*as-name:\s*(.+?)\s*$`)
reASNOrgName = regexp.MustCompile(`(?im)^\s*org-name:\s*(.+?)\s*$`)
reASNOwner = regexp.MustCompile(`(?im)^\s*owner:\s*(.+?)\s*$`)
reASNOwnerID = regexp.MustCompile(`(?im)^\s*ownerid:\s*(.+?)\s*$`)
reASNOrgRef = regexp.MustCompile(`(?im)^\s*org:\s*(.+?)\s*$`)
reASNDescr = regexp.MustCompile(`(?im)^\s*descr:\s*(.+?)\s*$`)
reASNCreated = regexp.MustCompile(`(?im)^\s*created:\s*(.+?)\s*$`)
reASNRegistered = regexp.MustCompile(`(?im)^\s*registered:\s*(.+?)\s*$`)
reASNLastMod = regexp.MustCompile(`(?im)^\s*last-modified:\s*(.+?)\s*$`)
reASNChanged = regexp.MustCompile(`(?im)^\s*changed:\s*(.+?)\s*$`)
reASNMailbox = regexp.MustCompile(`(?im)^\s*abuse-mailbox:\s*(\S+@\S+)\s*$`)
// APNIC/RIPE 会在响应顶部给一行注释:% Abuse contact for 'AS37963' is 'xxx@yyy'
reASNCommentAbuse = regexp.MustCompile(`(?i)%\s*Abuse contact for '?\s*AS\d+\s*'?\s*is\s*'?([^'\s]+)'?`)
// ARIN 字段(保持改造前语义;注意:这些正则在「多行整段」文本上用,必须带 m 标志)
reASNNumberArin = regexp.MustCompile(`(?im)^\s*ASNumber\s*[:=]\s*(.+?)\s*$`)
reASNNameArin = regexp.MustCompile(`(?im)^\s*ASName\s*[:=]\s*(.+?)\s*$`)
reASNHandleArin = regexp.MustCompile(`(?im)^\s*ASHandle\s*[:=]\s*(.+?)\s*$`)
reASNRegDateArin = regexp.MustCompile(`(?im)^\s*RegDate\s*[:=]\s*(.+?)\s*$`)
reASNUpdatedArin = regexp.MustCompile(`(?im)^\s*Updated\s*[:=]\s*(.+?)\s*$`)
reASNOrgNameArin = regexp.MustCompile(`(?im)^\s*OrgName\s*[:=]\s*(.+?)\s*$`)
reASNOrgIDArin = regexp.MustCompile(`(?im)^\s*OrgId\s*[:=]\s*(.+?)\s*$`)
reASNCountryArin = regexp.MustCompile(`(?im)^\s*Country\s*[:=]\s*([A-Z]{2})\s*$`)
reASNAbuseName = regexp.MustCompile(`(?im)^\s*OrgAbuseName\s*[:=]\s*(.+?)\s*$`)
reASNAbuseEmail = regexp.MustCompile(`(?im)^\s*OrgAbuseEmail\s*[:=]\s*(.+?)\s*$`)
reASNAbusePhone = regexp.MustCompile(`(?im)^\s*OrgAbusePhone\s*[:=]\s*(.+?)\s*$`)
// RIR 通用的「非 ASN 记录块」噪音(as-block 对象的 descr,如 "APNIC ASN block")
reASNBlockNoise = regexp.MustCompile(`(?i)(^|\s)ASN? block\s*$`)
// 滥用联系人的对象名:RIPE 用 irt:,APNIC 的 abuse-c 对象用 role:
reASNRoleName = regexp.MustCompile(`(?im)^\s*(?:irt|role):\s*(.+?)\s*$`)
reASNPhone = regexp.MustCompile(`(?im)^\s*phone:\s*(.+?)\s*$`)
)
// splitWhoisBlocks 按空行把响应切成记录块(每个 RIR 对象一段)
func splitWhoisBlocks(raw string) []string {
raw = strings.ReplaceAll(raw, "\r\n", "\n")
return strings.Split(raw, "\n\n")
}
// parseASNWhoisRaw 从原始 ASN WHOIS 响应中解析结构化数据
func parseASNWhoisRaw(raw string) *ASNWhoisResult {
result := &ASNWhoisResult{Raw: raw}
lines := strings.Split(raw, "\n")
for _, line := range lines {
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "%") || strings.HasPrefix(line, "#") {
continue
}
for field, pattern := range asnFieldPatterns {
if matches := pattern.FindStringSubmatch(line); len(matches) == 2 {
switch field {
case "asNumber":
result.ASNumber = matches[1]
case "asName":
result.ASName = matches[1]
case "asHandle":
if result.ASNumber == "" {
result.ASNumber = strings.TrimPrefix(matches[1], "AS")
}
case "regDate":
result.RegDate = matches[1]
case "updated":
result.Updated = matches[1]
case "orgName":
result.OrgName = matches[1]
case "orgId":
result.OrgID = matches[1]
case "country":
result.Country = matches[1]
case "abuseName":
result.AbuseName = matches[1]
case "abuseEmail":
result.AbuseEmail = matches[1]
case "abusePhone":
result.AbusePhone = matches[1]
}
// pickField 按给定优先级在文本中取第一个命中的字段值;全不命中返回 ""
func pickField(text string, patterns ...*regexp.Regexp) string {
for _, pattern := range patterns {
if m := pattern.FindStringSubmatch(text); len(m) == 2 {
if v := strings.TrimSpace(m[1]); v != "" {
return v
}
}
}
return ""
}
// findASNNativeBlock 找出含 aut-num 的原生记录块;没有返回 ""
func findASNNativeBlock(raw string) string {
for _, block := range splitWhoisBlocks(raw) {
if reASNAutNum.MatchString(block) {
return block
}
}
return ""
}
// findASNOrgBlock 按原生块里的 org: 句柄定位对应的 organisation/owner 对象
// (RIPE 的 org-name 与 country 不在 aut-num 对象里,而在 organisation 对象里)
func findASNOrgBlock(raw, nativeBlock string) string {
handle := pickField(nativeBlock, reASNOrgRef)
if handle == "" {
return ""
}
for _, block := range splitWhoisBlocks(raw) {
if strings.Contains(block, handle) && (reASNOrgName.MatchString(block) || reASNOwner.MatchString(block)) {
return block
}
}
return ""
}
// findASNOrgName 取组织名:organisation 对象的 org-name/owner(RIPE/APNIC/JPNIC)
// → 原生块首行 descr(APNIC 常见,如 "Hangzhou Alibaba Advertising Co.,Ltd."),并过滤 block 噪音
func findASNOrgName(orgBlock, nativeBlock string) string {
if v := pickField(orgBlock, reASNOrgName, reASNOwner); v != "" {
return v
}
for _, m := range reASNDescr.FindAllStringSubmatch(nativeBlock, -1) {
v := strings.TrimSpace(m[1])
if v == "" || reASNBlockNoise.MatchString(v) {
continue
}
return v
}
return ""
}
// findASNCommentAbuseEmail 取响应顶部注释里的 Abuse 邮箱(APNIC/RIPE 提供)
func findASNCommentAbuseEmail(raw string) string {
if m := reASNCommentAbuse.FindStringSubmatch(raw); len(m) == 2 {
return strings.TrimSpace(m[1])
}
return ""
}
// findASNMailboxAbuse 取含 abuse-mailbox 的记录块里的联系人名与电话(RIPE 的 irt:、APNIC 的 role:)
func findASNMailboxAbuse(raw string) (name, email, phone string) {
for _, block := range splitWhoisBlocks(raw) {
mailbox := pickField(block, reASNMailbox)
if mailbox == "" {
continue
}
return pickField(block, reASNRoleName), mailbox, pickField(block, reASNPhone)
}
return "", "", ""
}
// parseASNWhoisRaw 从原始 ASN WHOIS 响应中解析结构化数据(多 RIR)
func parseASNWhoisRaw(raw string) *ASNWhoisResult {
result := &ASNWhoisResult{Raw: raw}
// 情况一:有 RIR 原生记录(APNIC/RIPE/LACNIC/AFRINIC),只认它
if nativeBlock := findASNNativeBlock(raw); nativeBlock != "" {
orgBlock := findASNOrgBlock(raw, nativeBlock)
result.ASNumber = pickField(nativeBlock, reASNAutNum, reASNNumberArin)
result.ASName = pickField(nativeBlock, reASNAsName)
result.OrgName = findASNOrgName(orgBlock, nativeBlock)
result.OrgID = pickField(nativeBlock, reASNOrgRef, reASNOwnerID)
result.Country = pickField(nativeBlock, reASNCountryArin)
if result.Country == "" {
result.Country = pickField(orgBlock, reASNCountryArin)
}
result.RegDate = pickField(nativeBlock, reASNCreated, reASNRegistered, reASNRegDateArin)
result.Updated = pickField(nativeBlock, reASNLastMod, reASNUpdatedArin, reASNChanged)
// 滥用联系人:先用注释行(最权威),再用 abuse-mailbox 所在对象
result.AbuseEmail = findASNCommentAbuseEmail(raw)
name, email, phone := findASNMailboxAbuse(raw)
if result.AbuseName == "" {
result.AbuseName = name
}
if result.AbuseEmail == "" {
result.AbuseEmail = email
}
result.AbusePhone = phone
return result
}
// 情况二:ARIN 格式(ARIN 自有的 ASN),保持改造前行为
result.ASNumber = pickField(raw, reASNNumberArin)
if result.ASNumber == "" {
result.ASNumber = strings.TrimPrefix(pickField(raw, reASNHandleArin), "AS")
}
result.ASName = pickField(raw, reASNNameArin)
result.OrgName = pickField(raw, reASNOrgNameArin)
result.OrgID = pickField(raw, reASNOrgIDArin)
result.Country = pickField(raw, reASNCountryArin)
result.RegDate = pickField(raw, reASNRegDateArin)
result.Updated = pickField(raw, reASNUpdatedArin)
result.AbuseName = pickField(raw, reASNAbuseName)
result.AbuseEmail = pickField(raw, reASNAbuseEmail)
result.AbusePhone = pickField(raw, reASNAbusePhone)
return result
}
+3 -3
View File
@@ -234,9 +234,9 @@ func wsProbe(apiType, raw string, query map[string]string) (int, any) {
} else if errStr, ok := result["dbip_asn"].(string); ok {
asnResult["dbip_asn"] = map[string]string{"error": errStr}
}
if maxmindASN, ok := result["maxmind_asn"].(*ipdb.MMDBASNResult); ok {
whoisData, err := webtest.QueryASNWhois(maxmindASN.ASN)
if err == nil {
// WHOIS 进一步解析(与 /v1/asn/:ip 走同一套兜底与缓存)
if asn := resolveASN(result); asn != "" {
if whoisData, ok := lookupASNWhois(asn); ok {
asnResult["whois"] = whoisData
}
}