// Copyright 2026 tamakyi. All rights reserved.
// Use of this source code is governed by a MIT-style
// license that can be found in the LICENSE file.
package report
import (
"encoding/base64"
"fmt"
"html"
"math"
"os"
"path/filepath"
"strings"
"time"
)
// HTMLOptions 渲染选项。
type HTMLOptions struct {
// TencentMapKey 腾讯位置服务 key。留空则报告里只有离线 SVG 地图,
// 并附一句「需要可交互底图请配置 ip_geo_tencent_key」。
TencentMapKey string
}
// tencentKeyPlaceholder 未配置 key 时写进页面的占位符。
//
// 按地图合规要求,代码里绝不预置任何可用的 key;占位符要让拿到报告的人
// 一眼看出「这里需要你自己申请」,而不是以为地图坏了。
const tencentKeyPlaceholder = "请前往 lbs.qq.com 申请 key 后填入 [security] ip_geo_tencent_key"
// RenderHTML 生成完整的自包含报告页。
//
// 自包含的含义:不依赖 site 的 CSS、不依赖任何外链图片(腾讯地图那一块是
// 可选的在线增强,其余部分断网也能看)。
func RenderHTML(sum *Summary, opts HTMLOptions) string {
var b strings.Builder
title := sum.Period.Title()
b.WriteString(`
`)
b.WriteString(``)
fmt.Fprintf(&b, `%s`, html.EscapeString(title))
b.WriteString(pageCSS)
b.WriteString(``)
// 头部
fmt.Fprintf(&b, `
`,
html.EscapeString(title), html.EscapeString(sum.Period.Label()),
sum.GeneratedAt.Format("2006-01-02 15:04"))
if sum.Empty {
b.WriteString(`
本期没有捕获到任何蜜罐命中 —— 这本身是个好消息,但也请确认蜜罐仍然开启(TamaBox honeypot paths)。
`)
b.WriteString(`
`)
return b.String()
}
// KPI
b.WriteString(``)
writeKPI(&b, "蜜罐命中", fmt.Sprint(sum.TotalHits), trendNote(sum.TotalHits, sum.PrevHits))
writeKPI(&b, "独立来源", fmt.Sprint(sum.UniqueSources), fmt.Sprintf("覆盖 %d 个国家和地区", sum.UniqueCountry))
writeKPI(&b, "已拦截", fmt.Sprint(sum.BannedHits), pct(sum.BannedHits, sum.TotalHits))
writeKPI(&b, "真实破解尝试", fmt.Sprint(sum.CredHits), "提交了账号密码")
writeKPI(&b, "云主机来源", fmt.Sprint(sum.CloudSources), fmt.Sprintf("占比 %s", pctInt(int64(sum.CloudSources), int64(sum.UniqueSources))))
b.WriteString(``)
// 地图
b.WriteString(`来源分布
`)
b.WriteString(`气泡面积与命中次数成正比。地图上的点是 IP 库给出的城市级定位,精度到城市,不代表对方的真实街道位置。
`)
b.WriteString(``)
b.WriteString(RenderSVG(sum))
b.WriteString(`
`)
b.WriteString(`⚠️ 本图只画经纬网与气泡,不含任何国界。国界、台湾与南海诸岛的正确绘制须符合国家标准,自绘底图容易出错,因此这里不做底图;需要真实底图请看下方的腾讯地图版本。
`)
b.WriteString(``)
// 腾讯可交互地图
b.WriteString(`可交互地图
`)
b.WriteString(renderTencentMap(sum, opts.TencentMapKey))
b.WriteString(``)
// 国家排行
b.WriteString(`国家 / 地区排行
`)
b.WriteString(`| # | 国家 / 地区 | 命中 | 占比 | 独立来源 |
`)
for i, c := range sum.Countries {
if i >= 20 {
break
}
fmt.Fprintf(&b, `| %d | %s %s | %d | %s | %d |
`,
i+1, html.EscapeString(c.Name), html.EscapeString(c.Code), c.Hits, pctFloat(c.Share), c.Sources)
}
b.WriteString(`
`)
// 城市排行
if len(sum.Cities) > 0 {
b.WriteString(`城市排行(前 20)
`)
b.WriteString(`| # | 城市 | 国家 | 命中 | 独立来源 |
`)
for i, c := range sum.Cities {
if i >= 20 {
break
}
city := c.City
if city == "" {
city = c.Region
}
if city == "" {
city = "(未知城市)"
}
fmt.Fprintf(&b, `| %d | %s | %s | %d | %d |
`,
i+1, html.EscapeString(city), html.EscapeString(c.Country), c.Hits, c.Sources)
}
b.WriteString(`
`)
}
// 来源明细
b.WriteString(`来源明细(前 50)
`)
b.WriteString(`| IP | 位置 | 运营商 / ASN | 网络归属 | 命中 | 破解 | 拦截 |
`)
for i, s := range sum.Sources {
if i >= 50 {
break
}
provider := s.Provider
if provider == "" {
provider = "—"
}
isp := s.ISP
if s.ASN != "" {
if isp != "" {
isp += " · "
}
isp += s.ASN
}
if isp == "" {
isp = "—"
}
fmt.Fprintf(&b, `| %s | %s | %s | %s | %d | %d | %d |
`,
html.EscapeString(s.IP), html.EscapeString(s.Location()),
html.EscapeString(isp), html.EscapeString(provider),
s.Hits, s.Creds, s.Banned)
}
b.WriteString(`
`)
if len(sum.Sources) >= 50 {
fmt.Fprintf(&b, `共 %d 个独立来源,此处只列命中次数最高的 50 个。完整明细:TamaBox honeypot stats --days %d
`,
sum.UniqueSources, int(sum.Period.End.Sub(sum.Period.Start).Hours()/24))
}
b.WriteString(``)
// 路径
if len(sum.Paths) > 0 {
b.WriteString(`被探测路径
`)
b.WriteString(`| # | 路径 | 次数 |
`)
for i, p := range sum.Paths {
if i >= 20 {
break
}
fmt.Fprintf(&b, `| %d | %s | %d |
`,
i+1, html.EscapeString(p.Path), p.Hits)
}
b.WriteString(`
`)
}
// 趋势
if len(sum.Days) > 1 {
b.WriteString(`每日趋势
`)
b.WriteString(``)
for _, d := range sum.Days {
w := 0.0
if sum.MaxDay > 0 {
w = float64(d.Hits) / float64(sum.MaxDay) * 100
}
// 宽度为 0 的日子也给 1px,否则读起来像「数据缺失」而不是「零」。
barW := math.Max(w, 0.6)
fmt.Fprintf(&b, `| %s | | %d |
`,
d.Day.Format("01-02"), barW, d.Hits)
}
b.WriteString(`
`)
}
// 口径说明
b.WriteString(``)
fmt.Fprintf(&b, ``, sum.GeneratedAt.Format("2006-01-02 15:04:05"))
b.WriteString(`