2026-05-13 14:42:45 +08:00
|
|
|
|
//go:build llm_script
|
|
|
|
|
|
|
|
|
|
|
|
// generate_daily_report.go v3.0 - 日报生成器(现代化UI版)
|
|
|
|
|
|
// 支持:国家分类、运营商分类、信息图风格HTML
|
2026-05-08 13:49:12 +08:00
|
|
|
|
package main
|
|
|
|
|
|
|
|
|
|
|
|
import (
|
2026-05-13 14:42:45 +08:00
|
|
|
|
"database/sql"
|
2026-05-08 13:49:12 +08:00
|
|
|
|
"encoding/json"
|
|
|
|
|
|
"fmt"
|
2026-05-13 14:42:45 +08:00
|
|
|
|
"html/template"
|
|
|
|
|
|
"log/slog"
|
2026-05-08 13:49:12 +08:00
|
|
|
|
"os"
|
|
|
|
|
|
"path/filepath"
|
|
|
|
|
|
"sort"
|
|
|
|
|
|
"strings"
|
|
|
|
|
|
"time"
|
2026-05-13 14:42:45 +08:00
|
|
|
|
|
|
|
|
|
|
_ "github.com/lib/pq"
|
2026-05-08 13:49:12 +08:00
|
|
|
|
)
|
|
|
|
|
|
|
2026-05-13 14:42:45 +08:00
|
|
|
|
var logger *slog.Logger
|
|
|
|
|
|
|
|
|
|
|
|
func init() {
|
|
|
|
|
|
logger = slog.New(slog.NewJSONHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelInfo}))
|
2026-05-08 13:49:12 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-13 14:42:45 +08:00
|
|
|
|
func main() {
|
|
|
|
|
|
loadProjectEnv()
|
|
|
|
|
|
if err := run(); err != nil {
|
|
|
|
|
|
logger.Error("日报生成失败", "error", err)
|
|
|
|
|
|
os.Exit(1)
|
|
|
|
|
|
}
|
|
|
|
|
|
logger.Info("日报生成完成")
|
2026-05-08 13:49:12 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-13 14:42:45 +08:00
|
|
|
|
func loadProjectEnv() {
|
|
|
|
|
|
for _, path := range []string{".env.local", ".env"} {
|
|
|
|
|
|
loadEnvFile(path)
|
|
|
|
|
|
}
|
2026-05-08 13:49:12 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-13 14:42:45 +08:00
|
|
|
|
func loadEnvFile(path string) {
|
|
|
|
|
|
f, err := os.Open(path)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
defer f.Close()
|
2026-05-08 13:49:12 +08:00
|
|
|
|
|
2026-05-13 14:42:45 +08:00
|
|
|
|
buf := make([]byte, 4096)
|
|
|
|
|
|
n, _ := f.Read(buf)
|
|
|
|
|
|
content := string(buf[:n])
|
|
|
|
|
|
for _, line := range strings.Split(content, "\n") {
|
|
|
|
|
|
line = strings.TrimSpace(line)
|
|
|
|
|
|
if line == "" || strings.HasPrefix(line, "#") {
|
|
|
|
|
|
continue
|
|
|
|
|
|
}
|
|
|
|
|
|
key, value, ok := strings.Cut(line, "=")
|
|
|
|
|
|
if !ok {
|
|
|
|
|
|
continue
|
|
|
|
|
|
}
|
|
|
|
|
|
key = strings.TrimSpace(key)
|
|
|
|
|
|
value = strings.TrimSpace(value)
|
|
|
|
|
|
value = strings.Trim(value, `"'`)
|
|
|
|
|
|
if key == "" {
|
|
|
|
|
|
continue
|
|
|
|
|
|
}
|
|
|
|
|
|
if _, exists := os.LookupEnv(key); exists {
|
|
|
|
|
|
continue
|
|
|
|
|
|
}
|
|
|
|
|
|
_ = os.Setenv(key, value)
|
2026-05-08 13:49:12 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-13 14:42:45 +08:00
|
|
|
|
func run() error {
|
|
|
|
|
|
dbConn := os.Getenv("DATABASE_URL")
|
|
|
|
|
|
if dbConn == "" {
|
|
|
|
|
|
return fmt.Errorf("DATABASE_URL 未设置")
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
db, err := sql.Open("postgres", dbConn)
|
2026-05-08 13:49:12 +08:00
|
|
|
|
if err != nil {
|
2026-05-13 14:42:45 +08:00
|
|
|
|
return fmt.Errorf("连接数据库失败: %w", err)
|
2026-05-08 13:49:12 +08:00
|
|
|
|
}
|
2026-05-13 14:42:45 +08:00
|
|
|
|
defer db.Close()
|
2026-05-08 13:49:12 +08:00
|
|
|
|
|
2026-05-13 14:42:45 +08:00
|
|
|
|
date := time.Now().Format("2006-01-02")
|
|
|
|
|
|
|
|
|
|
|
|
// 1. 获取报告数据(使用新schema)
|
|
|
|
|
|
report, err := generateReportDataV3(db, date)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return fmt.Errorf("生成报告数据失败: %w", err)
|
2026-05-08 13:49:12 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-13 14:42:45 +08:00
|
|
|
|
// 2. 创建目录
|
|
|
|
|
|
outDir := "reports/daily"
|
|
|
|
|
|
os.MkdirAll(outDir, 0755)
|
|
|
|
|
|
os.MkdirAll(outDir+"/html", 0755)
|
|
|
|
|
|
|
|
|
|
|
|
// 3. 生成 Markdown
|
|
|
|
|
|
mdPath := filepath.Join(outDir, fmt.Sprintf("daily_report_%s.md", date))
|
|
|
|
|
|
if err := generateMarkdownV3(report, mdPath); err != nil {
|
|
|
|
|
|
return err
|
2026-05-08 13:49:12 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-13 14:42:45 +08:00
|
|
|
|
// 4. 生成 HTML(现代化UI)
|
|
|
|
|
|
htmlPath := filepath.Join(outDir, "html", fmt.Sprintf("daily_report_%s.html", date))
|
|
|
|
|
|
if err := generateHTMLV3(report, htmlPath); err != nil {
|
|
|
|
|
|
return err
|
2026-05-08 13:49:12 +08:00
|
|
|
|
}
|
2026-05-13 14:42:45 +08:00
|
|
|
|
|
|
|
|
|
|
// 5. 保存到 daily_report 表
|
|
|
|
|
|
if err := saveDailyReportV3(db, report, mdPath); err != nil {
|
|
|
|
|
|
logger.Warn("保存日报记录失败", "error", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
logger.Info("日报生成完成",
|
|
|
|
|
|
"models", report.TotalModels,
|
|
|
|
|
|
"free", len(report.FreeModels),
|
|
|
|
|
|
"intl", len(report.IntlTop5),
|
|
|
|
|
|
"domestic", len(report.DomesticTop10),
|
|
|
|
|
|
"md", mdPath,
|
|
|
|
|
|
"html", htmlPath)
|
|
|
|
|
|
return nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ============ 数据模型 ============
|
|
|
|
|
|
|
|
|
|
|
|
const (
|
|
|
|
|
|
USD_TO_CNY = 7.25 // USD 转 CNY 汇率
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
type ModelInfo struct {
|
|
|
|
|
|
ID, Name, ProviderName string
|
|
|
|
|
|
ProviderCountry string
|
|
|
|
|
|
ContextLength int
|
|
|
|
|
|
InputPrice, OutputPrice float64
|
|
|
|
|
|
Currency string
|
|
|
|
|
|
IsFree bool
|
|
|
|
|
|
OperatorName string
|
|
|
|
|
|
OperatorType string // cloud / reseller / official
|
|
|
|
|
|
Region string
|
|
|
|
|
|
Modality string
|
|
|
|
|
|
SceneTags []SceneTag
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
type ReportV3 struct {
|
|
|
|
|
|
Date string
|
|
|
|
|
|
TotalModels int
|
|
|
|
|
|
FreeModels []ModelInfo
|
|
|
|
|
|
FreeTop20 []ModelInfo // 免费模型前20个(展示用)
|
|
|
|
|
|
IntlTop5 []ModelInfo // 国际前5(付费低价)
|
|
|
|
|
|
DomesticTop10 []ModelInfo // 国内前10(付费低价)
|
|
|
|
|
|
TopContext []ModelInfo // 大上下文TOP10
|
|
|
|
|
|
TencentSubscriptionPlans []SubscriptionPlanInfo
|
|
|
|
|
|
Operators []OperatorInfo
|
|
|
|
|
|
Resellers []OperatorInfo
|
|
|
|
|
|
QualitySummary DataQualitySummary
|
|
|
|
|
|
HasCNYData bool
|
|
|
|
|
|
HasDomesticData bool
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
type OperatorInfo struct {
|
|
|
|
|
|
Name, Type, Country string
|
|
|
|
|
|
ModelCount int
|
|
|
|
|
|
AvgInputPrice float64
|
|
|
|
|
|
MinInputPrice float64
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
type DataQualitySummary struct {
|
|
|
|
|
|
Total, Fresh, Stale, CNY, USD int
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
type SubscriptionPlanInfo struct {
|
|
|
|
|
|
PlanName string
|
|
|
|
|
|
PlanFamily string
|
|
|
|
|
|
Tier string
|
|
|
|
|
|
Currency string
|
|
|
|
|
|
ListPrice float64
|
|
|
|
|
|
QuotaValue int64
|
|
|
|
|
|
QuotaUnit string
|
|
|
|
|
|
ContextWindow int
|
|
|
|
|
|
ModelCount int
|
|
|
|
|
|
ModelPreview string
|
|
|
|
|
|
SourceURL string
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ============ 数据查询(新Schema) ============
|
|
|
|
|
|
|
|
|
|
|
|
func generateReportDataV3(db *sql.DB, date string) (*ReportV3, error) {
|
|
|
|
|
|
// 查询模型+厂商+定价+运营商信息
|
|
|
|
|
|
rows, err := db.Query(`
|
|
|
|
|
|
WITH latest_prices AS (
|
|
|
|
|
|
SELECT
|
|
|
|
|
|
rp.model_id,
|
|
|
|
|
|
rp.input_price_per_mtok,
|
|
|
|
|
|
rp.output_price_per_mtok,
|
|
|
|
|
|
rp.currency,
|
|
|
|
|
|
rp.region,
|
|
|
|
|
|
rp.is_free,
|
|
|
|
|
|
o.name as operator_name,
|
|
|
|
|
|
COALESCE(o.name_cn, o.name) as operator_name_cn,
|
|
|
|
|
|
COALESCE(o.type, 'reseller') as operator_type,
|
|
|
|
|
|
ROW_NUMBER() OVER (
|
|
|
|
|
|
PARTITION BY rp.model_id
|
|
|
|
|
|
ORDER BY rp.effective_date DESC NULLS LAST, rp.id DESC
|
|
|
|
|
|
) AS rn
|
|
|
|
|
|
FROM region_pricing rp
|
|
|
|
|
|
LEFT JOIN operator o ON rp.operator_id = o.id
|
|
|
|
|
|
)
|
|
|
|
|
|
SELECT
|
|
|
|
|
|
m.external_id,
|
|
|
|
|
|
COALESCE(NULLIF(m.name, ''), m.external_id) as name,
|
|
|
|
|
|
COALESCE(mp.name, split_part(m.external_id, '/', 1)) as provider_name,
|
|
|
|
|
|
COALESCE(mp.country, 'unknown') as provider_country,
|
|
|
|
|
|
COALESCE(m.context_length, 0),
|
|
|
|
|
|
m.modality,
|
|
|
|
|
|
COALESCE(lp.input_price_per_mtok, 0),
|
|
|
|
|
|
COALESCE(lp.output_price_per_mtok, 0),
|
|
|
|
|
|
COALESCE(lp.currency, 'USD'),
|
|
|
|
|
|
COALESCE(lp.is_free, false),
|
|
|
|
|
|
COALESCE(lp.operator_name, 'OpenRouter'),
|
|
|
|
|
|
COALESCE(lp.operator_type, 'reseller'),
|
|
|
|
|
|
COALESCE(lp.region, 'global')
|
|
|
|
|
|
FROM models m
|
|
|
|
|
|
LEFT JOIN model_provider mp ON m.provider_id = mp.id
|
|
|
|
|
|
LEFT JOIN latest_prices lp ON lp.model_id = m.id AND lp.rn = 1
|
|
|
|
|
|
WHERE m.deleted_at IS NULL
|
|
|
|
|
|
ORDER BY m.id
|
|
|
|
|
|
`)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return nil, err
|
2026-05-08 13:49:12 +08:00
|
|
|
|
}
|
2026-05-13 14:42:45 +08:00
|
|
|
|
defer rows.Close()
|
|
|
|
|
|
|
|
|
|
|
|
var allModels []ModelInfo
|
|
|
|
|
|
var freeModels []ModelInfo
|
|
|
|
|
|
var intlModels []ModelInfo // 国际模型(US/EU/unknown)
|
|
|
|
|
|
var domesticModels []ModelInfo // 国内模型(CN)
|
2026-05-08 13:49:12 +08:00
|
|
|
|
|
2026-05-13 14:42:45 +08:00
|
|
|
|
providerSet := make(map[string]struct{})
|
|
|
|
|
|
operatorSet := make(map[string]OperatorInfo)
|
|
|
|
|
|
|
|
|
|
|
|
for rows.Next() {
|
|
|
|
|
|
var m ModelInfo
|
|
|
|
|
|
if err := rows.Scan(
|
|
|
|
|
|
&m.ID, &m.Name, &m.ProviderName, &m.ProviderCountry,
|
|
|
|
|
|
&m.ContextLength, &m.Modality,
|
|
|
|
|
|
&m.InputPrice, &m.OutputPrice,
|
|
|
|
|
|
&m.Currency, &m.IsFree,
|
|
|
|
|
|
&m.OperatorName, &m.OperatorType, &m.Region,
|
|
|
|
|
|
); err != nil {
|
|
|
|
|
|
logger.Warn("扫描模型数据失败", "error", err)
|
|
|
|
|
|
continue
|
|
|
|
|
|
}
|
|
|
|
|
|
m.SceneTags = deriveSceneTags(m.Name, m.Modality, nil)
|
|
|
|
|
|
allModels = append(allModels, m)
|
|
|
|
|
|
|
|
|
|
|
|
if m.IsFree {
|
2026-05-08 13:49:12 +08:00
|
|
|
|
freeModels = append(freeModels, m)
|
|
|
|
|
|
}
|
2026-05-13 14:42:45 +08:00
|
|
|
|
|
|
|
|
|
|
// 国家分类 - 国内官方平台 vs OpenRouter上的国内模型
|
|
|
|
|
|
isDomesticOfficial := (m.ProviderCountry == "CN" || m.ProviderCountry == "cn") &&
|
|
|
|
|
|
(m.OperatorType == "official" || m.OperatorType == "cloud")
|
|
|
|
|
|
isDomesticReseller := (m.ProviderCountry == "CN" || m.ProviderCountry == "cn") &&
|
|
|
|
|
|
m.OperatorType == "reseller"
|
|
|
|
|
|
|
|
|
|
|
|
if isDomesticOfficial {
|
|
|
|
|
|
domesticModels = append(domesticModels, m)
|
|
|
|
|
|
} else if isDomesticReseller {
|
|
|
|
|
|
// OpenRouter上的国内模型,归入国际分类但标记
|
|
|
|
|
|
intlModels = append(intlModels, m)
|
|
|
|
|
|
} else {
|
|
|
|
|
|
intlModels = append(intlModels, m)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
providerSet[m.ProviderName] = struct{}{}
|
|
|
|
|
|
|
|
|
|
|
|
// 统计运营商
|
|
|
|
|
|
op := operatorSet[m.OperatorName]
|
|
|
|
|
|
op.Name = m.OperatorName
|
|
|
|
|
|
op.Type = m.OperatorType
|
|
|
|
|
|
op.Country = m.ProviderCountry
|
|
|
|
|
|
op.ModelCount++
|
|
|
|
|
|
if op.MinInputPrice == 0 || m.InputPrice < op.MinInputPrice {
|
|
|
|
|
|
op.MinInputPrice = m.InputPrice
|
|
|
|
|
|
}
|
|
|
|
|
|
op.AvgInputPrice = (op.AvgInputPrice*float64(op.ModelCount-1) + m.InputPrice) / float64(op.ModelCount)
|
|
|
|
|
|
operatorSet[m.OperatorName] = op
|
2026-05-08 13:49:12 +08:00
|
|
|
|
}
|
2026-05-13 14:42:45 +08:00
|
|
|
|
|
|
|
|
|
|
// 排序
|
|
|
|
|
|
sort.Slice(intlModels, func(i, j int) bool {
|
|
|
|
|
|
if intlModels[i].IsFree != intlModels[j].IsFree {
|
|
|
|
|
|
return intlModels[i].IsFree
|
|
|
|
|
|
}
|
|
|
|
|
|
return intlModels[i].InputPrice < intlModels[j].InputPrice
|
|
|
|
|
|
})
|
|
|
|
|
|
sort.Slice(domesticModels, func(i, j int) bool {
|
|
|
|
|
|
if domesticModels[i].IsFree != domesticModels[j].IsFree {
|
|
|
|
|
|
return domesticModels[i].IsFree
|
|
|
|
|
|
}
|
|
|
|
|
|
return domesticModels[i].InputPrice < domesticModels[j].InputPrice
|
|
|
|
|
|
})
|
2026-05-08 13:49:12 +08:00
|
|
|
|
sort.Slice(freeModels, func(i, j int) bool {
|
|
|
|
|
|
return freeModels[i].ContextLength > freeModels[j].ContextLength
|
|
|
|
|
|
})
|
2026-05-13 14:42:45 +08:00
|
|
|
|
|
|
|
|
|
|
// 提取TOP - 国际排除免费,国内包含免费(展示真实低价+免费精选)
|
|
|
|
|
|
var intlTop5 []ModelInfo
|
|
|
|
|
|
intlPaid := filterPaid(intlModels)
|
|
|
|
|
|
if len(intlPaid) > 5 {
|
|
|
|
|
|
intlTop5 = intlPaid[:5]
|
|
|
|
|
|
} else {
|
|
|
|
|
|
intlTop5 = intlPaid
|
2026-05-08 13:49:12 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-13 14:42:45 +08:00
|
|
|
|
var domesticTop10 []ModelInfo
|
|
|
|
|
|
// 国内模型:优先展示付费低价,然后补充免费模型
|
|
|
|
|
|
domesticPaid := filterPaid(domesticModels)
|
|
|
|
|
|
domesticTop10 = append(domesticTop10, domesticPaid...)
|
|
|
|
|
|
// 补充免费国内模型(按上下文排序)
|
|
|
|
|
|
var domesticFree []ModelInfo
|
|
|
|
|
|
for _, m := range domesticModels {
|
|
|
|
|
|
if m.IsFree {
|
|
|
|
|
|
domesticFree = append(domesticFree, m)
|
2026-05-08 13:49:12 +08:00
|
|
|
|
}
|
2026-05-13 14:42:45 +08:00
|
|
|
|
}
|
|
|
|
|
|
sort.Slice(domesticFree, func(i, j int) bool {
|
|
|
|
|
|
return domesticFree[i].ContextLength > domesticFree[j].ContextLength
|
|
|
|
|
|
})
|
|
|
|
|
|
for _, m := range domesticFree {
|
|
|
|
|
|
if len(domesticTop10) >= 10 {
|
|
|
|
|
|
break
|
|
|
|
|
|
}
|
|
|
|
|
|
domesticTop10 = append(domesticTop10, m)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 免费模型只展示前20个 + 分类统计
|
|
|
|
|
|
var freeTop20 []ModelInfo
|
|
|
|
|
|
if len(freeModels) > 20 {
|
|
|
|
|
|
freeTop20 = freeModels[:20]
|
2026-05-08 13:49:12 +08:00
|
|
|
|
} else {
|
2026-05-13 14:42:45 +08:00
|
|
|
|
freeTop20 = freeModels
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 如果付费不足,用免费模型补充"推荐"
|
|
|
|
|
|
if len(intlTop5) == 0 && len(intlModels) > 0 {
|
|
|
|
|
|
if len(intlModels) > 5 {
|
|
|
|
|
|
intlTop5 = intlModels[:5]
|
|
|
|
|
|
} else {
|
|
|
|
|
|
intlTop5 = intlModels
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
if len(domesticTop10) == 0 && len(domesticModels) > 0 {
|
|
|
|
|
|
if len(domesticModels) > 10 {
|
|
|
|
|
|
domesticTop10 = domesticModels[:10]
|
|
|
|
|
|
} else {
|
|
|
|
|
|
domesticTop10 = domesticModels
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 运营商分类
|
|
|
|
|
|
var operators, resellers []OperatorInfo
|
|
|
|
|
|
for _, op := range operatorSet {
|
|
|
|
|
|
if op.Type == "cloud" || op.Type == "official" {
|
|
|
|
|
|
operators = append(operators, op)
|
|
|
|
|
|
} else {
|
|
|
|
|
|
resellers = append(resellers, op)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 数据质量统计
|
|
|
|
|
|
var fresh, stale, cny, usd int
|
|
|
|
|
|
for _, m := range allModels {
|
|
|
|
|
|
if m.InputPrice > 0 || m.IsFree {
|
|
|
|
|
|
fresh++
|
|
|
|
|
|
} else {
|
|
|
|
|
|
stale++
|
|
|
|
|
|
}
|
|
|
|
|
|
if m.Currency == "CNY" {
|
|
|
|
|
|
cny++
|
|
|
|
|
|
} else if m.Currency == "USD" {
|
|
|
|
|
|
usd++
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
tencentPlans, err := loadTencentSubscriptionPlans(db)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return nil, err
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return &ReportV3{
|
|
|
|
|
|
Date: date,
|
|
|
|
|
|
TotalModels: len(allModels),
|
|
|
|
|
|
FreeModels: freeModels,
|
|
|
|
|
|
FreeTop20: freeTop20,
|
|
|
|
|
|
IntlTop5: intlTop5,
|
|
|
|
|
|
DomesticTop10: domesticTop10,
|
|
|
|
|
|
TencentSubscriptionPlans: tencentPlans,
|
|
|
|
|
|
Operators: operators,
|
|
|
|
|
|
Resellers: resellers,
|
|
|
|
|
|
HasCNYData: cny > 0,
|
|
|
|
|
|
HasDomesticData: len(domesticModels) > 0,
|
|
|
|
|
|
QualitySummary: DataQualitySummary{
|
|
|
|
|
|
Total: len(allModels),
|
|
|
|
|
|
Fresh: fresh,
|
|
|
|
|
|
Stale: stale,
|
|
|
|
|
|
CNY: cny,
|
|
|
|
|
|
USD: usd,
|
|
|
|
|
|
},
|
|
|
|
|
|
}, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func loadTencentSubscriptionPlans(db *sql.DB) ([]SubscriptionPlanInfo, error) {
|
|
|
|
|
|
rows, err := db.Query(`
|
|
|
|
|
|
SELECT
|
|
|
|
|
|
sp.plan_name,
|
|
|
|
|
|
sp.plan_family,
|
|
|
|
|
|
sp.tier,
|
|
|
|
|
|
sp.currency,
|
|
|
|
|
|
sp.list_price,
|
|
|
|
|
|
COALESCE(sp.quota_value, 0),
|
|
|
|
|
|
COALESCE(sp.quota_unit, ''),
|
|
|
|
|
|
COALESCE(sp.context_window, 0),
|
|
|
|
|
|
COALESCE(sp.model_scope, '[]'),
|
|
|
|
|
|
COALESCE(sp.source_url, '')
|
|
|
|
|
|
FROM subscription_plan sp
|
|
|
|
|
|
JOIN model_provider mp ON mp.id = sp.provider_id
|
|
|
|
|
|
WHERE mp.name = 'Tencent'
|
|
|
|
|
|
ORDER BY sp.list_price ASC, sp.plan_name ASC
|
|
|
|
|
|
`)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
if strings.Contains(err.Error(), `relation "subscription_plan" does not exist`) {
|
|
|
|
|
|
return nil, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
return nil, err
|
|
|
|
|
|
}
|
|
|
|
|
|
defer rows.Close()
|
|
|
|
|
|
|
|
|
|
|
|
var plans []SubscriptionPlanInfo
|
|
|
|
|
|
for rows.Next() {
|
|
|
|
|
|
var plan SubscriptionPlanInfo
|
|
|
|
|
|
var modelScopeRaw string
|
|
|
|
|
|
if err := rows.Scan(
|
|
|
|
|
|
&plan.PlanName,
|
|
|
|
|
|
&plan.PlanFamily,
|
|
|
|
|
|
&plan.Tier,
|
|
|
|
|
|
&plan.Currency,
|
|
|
|
|
|
&plan.ListPrice,
|
|
|
|
|
|
&plan.QuotaValue,
|
|
|
|
|
|
&plan.QuotaUnit,
|
|
|
|
|
|
&plan.ContextWindow,
|
|
|
|
|
|
&modelScopeRaw,
|
|
|
|
|
|
&plan.SourceURL,
|
|
|
|
|
|
); err != nil {
|
|
|
|
|
|
return nil, err
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
var modelIDs []string
|
|
|
|
|
|
if err := json.Unmarshal([]byte(modelScopeRaw), &modelIDs); err == nil {
|
|
|
|
|
|
plan.ModelCount = len(modelIDs)
|
|
|
|
|
|
if len(modelIDs) > 3 {
|
|
|
|
|
|
plan.ModelPreview = strings.Join(modelIDs[:3], ", ")
|
|
|
|
|
|
} else {
|
|
|
|
|
|
plan.ModelPreview = strings.Join(modelIDs, ", ")
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
plans = append(plans, plan)
|
|
|
|
|
|
}
|
|
|
|
|
|
return plans, rows.Err()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func filterPaid(models []ModelInfo) []ModelInfo {
|
|
|
|
|
|
var paid []ModelInfo
|
|
|
|
|
|
for _, m := range models {
|
|
|
|
|
|
if !m.IsFree && m.InputPrice > 0 {
|
|
|
|
|
|
paid = append(paid, m)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
sort.Slice(paid, func(i, j int) bool {
|
|
|
|
|
|
return paid[i].InputPrice < paid[j].InputPrice
|
|
|
|
|
|
})
|
|
|
|
|
|
return paid
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func formatPrice(price float64, currency string) string {
|
|
|
|
|
|
if price <= 0 {
|
|
|
|
|
|
return "免费"
|
|
|
|
|
|
}
|
|
|
|
|
|
if currency == "CNY" {
|
|
|
|
|
|
if price < 1 {
|
|
|
|
|
|
return fmt.Sprintf("¥%.2f", price)
|
|
|
|
|
|
}
|
|
|
|
|
|
return fmt.Sprintf("¥%.1f", price)
|
|
|
|
|
|
}
|
|
|
|
|
|
// USD - convert to CNY for display
|
|
|
|
|
|
cny := price * USD_TO_CNY
|
|
|
|
|
|
if cny < 1 {
|
|
|
|
|
|
return fmt.Sprintf("¥%.2f", cny)
|
|
|
|
|
|
}
|
|
|
|
|
|
return fmt.Sprintf("¥%.1f", cny)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func formatPriceWithCurrency(price float64, currency string) string {
|
|
|
|
|
|
if price <= 0 {
|
|
|
|
|
|
return "免费"
|
|
|
|
|
|
}
|
|
|
|
|
|
if currency == "CNY" {
|
|
|
|
|
|
return fmt.Sprintf("¥%.2f", price)
|
|
|
|
|
|
}
|
|
|
|
|
|
return fmt.Sprintf("$%.2f", price)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// formatDomesticPrice 显示国内模型价格(统一转换为CNY)
|
|
|
|
|
|
func formatDomesticPrice(price float64, currency string) string {
|
|
|
|
|
|
if price <= 0 {
|
|
|
|
|
|
return "免费"
|
|
|
|
|
|
}
|
|
|
|
|
|
if currency == "USD" {
|
|
|
|
|
|
price = price * USD_TO_CNY
|
|
|
|
|
|
}
|
|
|
|
|
|
return fmt.Sprintf("¥%.2f", price)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Deprecated: use formatPrice
|
|
|
|
|
|
func formatCNY(price float64) string {
|
|
|
|
|
|
return formatPrice(price, "USD")
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func formatPriceUSD(price float64) string {
|
|
|
|
|
|
if price <= 0 {
|
|
|
|
|
|
return "免费"
|
|
|
|
|
|
}
|
|
|
|
|
|
return fmt.Sprintf("$%.2f", price)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func formatSubscriptionPrice(price float64, currency string) string {
|
|
|
|
|
|
switch currency {
|
|
|
|
|
|
case "CNY":
|
|
|
|
|
|
return fmt.Sprintf("¥%.2f/月", price)
|
|
|
|
|
|
case "USD":
|
|
|
|
|
|
return fmt.Sprintf("$%.2f/month", price)
|
|
|
|
|
|
default:
|
|
|
|
|
|
return fmt.Sprintf("%.2f %s", price, currency)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func formatSubscriptionQuota(value int64, unit string) string {
|
|
|
|
|
|
if value <= 0 {
|
|
|
|
|
|
return "-"
|
|
|
|
|
|
}
|
|
|
|
|
|
if unit == "tokens/month" {
|
|
|
|
|
|
switch {
|
|
|
|
|
|
case value%10000 == 0 && value < 100000000:
|
|
|
|
|
|
return fmt.Sprintf("%d万 Tokens/月", value/10000)
|
|
|
|
|
|
case value%100000000 == 0:
|
|
|
|
|
|
return fmt.Sprintf("%d亿 Tokens/月", value/100000000)
|
|
|
|
|
|
case value >= 10000000:
|
|
|
|
|
|
return fmt.Sprintf("%.1f亿 Tokens/月", float64(value)/100000000)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
return fmt.Sprintf("%d %s", value, unit)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func formatContextWindowCompact(value int) string {
|
|
|
|
|
|
if value <= 0 {
|
|
|
|
|
|
return "-"
|
|
|
|
|
|
}
|
|
|
|
|
|
if value%(1024*1024) == 0 {
|
|
|
|
|
|
return fmt.Sprintf("%dM", value/(1024*1024))
|
|
|
|
|
|
}
|
|
|
|
|
|
if value%1024 == 0 {
|
|
|
|
|
|
return fmt.Sprintf("%dK", value/1024)
|
2026-05-08 13:49:12 +08:00
|
|
|
|
}
|
2026-05-13 14:42:45 +08:00
|
|
|
|
return fmt.Sprintf("%d", value)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 场景标签
|
|
|
|
|
|
type SceneTag string
|
|
|
|
|
|
|
|
|
|
|
|
const (
|
|
|
|
|
|
SceneCode SceneTag = "代码"
|
|
|
|
|
|
SceneReasoning SceneTag = "推理"
|
|
|
|
|
|
SceneWriting SceneTag = "写作"
|
|
|
|
|
|
SceneVision SceneTag = "视觉"
|
|
|
|
|
|
SceneChat SceneTag = "对话"
|
|
|
|
|
|
)
|
2026-05-08 13:49:12 +08:00
|
|
|
|
|
2026-05-13 14:42:45 +08:00
|
|
|
|
func deriveSceneTags(name, modality string, capabilities []string) []SceneTag {
|
|
|
|
|
|
var tags []SceneTag
|
|
|
|
|
|
lowerName := strings.ToLower(name)
|
|
|
|
|
|
|
|
|
|
|
|
// 代码模型
|
|
|
|
|
|
if strings.Contains(lowerName, "codex") || strings.Contains(lowerName, "coder") ||
|
|
|
|
|
|
strings.Contains(lowerName, "code") || strings.Contains(modality, "code") {
|
|
|
|
|
|
tags = append(tags, SceneCode)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 推理模型
|
|
|
|
|
|
if strings.Contains(lowerName, "o1") || strings.Contains(lowerName, "o3") ||
|
|
|
|
|
|
strings.Contains(lowerName, "o4") || strings.Contains(lowerName, "reasoning") ||
|
|
|
|
|
|
strings.Contains(lowerName, "r1") || strings.Contains(lowerName, "thinking") {
|
|
|
|
|
|
tags = append(tags, SceneReasoning)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 视觉模型
|
|
|
|
|
|
if strings.Contains(modality, "vision") || strings.Contains(modality, "multimodal") ||
|
|
|
|
|
|
strings.Contains(lowerName, "vl") || strings.Contains(lowerName, "vision") {
|
|
|
|
|
|
tags = append(tags, SceneVision)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 写作/对话(兜底)
|
|
|
|
|
|
if len(tags) == 0 {
|
|
|
|
|
|
if strings.Contains(modality, "text") || strings.Contains(modality, "chat") {
|
|
|
|
|
|
tags = append(tags, SceneChat)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return tags
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ============ Markdown生成 ============
|
|
|
|
|
|
|
|
|
|
|
|
func generateMarkdownV3(r *ReportV3, path string) error {
|
|
|
|
|
|
f, err := os.Create(path)
|
2026-05-08 13:49:12 +08:00
|
|
|
|
if err != nil {
|
2026-05-13 14:42:45 +08:00
|
|
|
|
return err
|
2026-05-08 13:49:12 +08:00
|
|
|
|
}
|
|
|
|
|
|
defer f.Close()
|
|
|
|
|
|
|
2026-05-13 14:42:45 +08:00
|
|
|
|
fmt.Fprintf(f, "# 🤖 LLM Intelligence Hub - 每日情报报告\n\n")
|
|
|
|
|
|
fmt.Fprintf(f, "**报告日期**: %s \n**生成时间**: %s \n\n", r.Date, time.Now().Format(time.RFC3339))
|
|
|
|
|
|
|
|
|
|
|
|
// 数据质量摘要
|
|
|
|
|
|
fmt.Fprintf(f, "## 📊 数据质量摘要\n\n")
|
2026-05-08 13:49:12 +08:00
|
|
|
|
fmt.Fprintf(f, "| 指标 | 数值 |\n|------|------|\n")
|
2026-05-13 14:42:45 +08:00
|
|
|
|
fmt.Fprintf(f, "| 模型总数 | %d |\n", r.QualitySummary.Total)
|
|
|
|
|
|
fmt.Fprintf(f, "| 数据新鲜 | %d |\n", r.QualitySummary.Fresh)
|
|
|
|
|
|
fmt.Fprintf(f, "| CNY定价 | %d |\n", r.QualitySummary.CNY)
|
|
|
|
|
|
fmt.Fprintf(f, "| USD定价 | %d |\n", r.QualitySummary.USD)
|
|
|
|
|
|
fmt.Fprintf(f, "| 厂商总数 | %d |\n\n", len(r.IntlTop5)+len(r.DomesticTop10))
|
|
|
|
|
|
|
|
|
|
|
|
// 免费模型(只展示前20个 + 分类统计)
|
|
|
|
|
|
if len(r.FreeModels) > 0 {
|
|
|
|
|
|
fmt.Fprintf(f, "## 🆓 免费模型(共 %d 个)\n\n", len(r.FreeModels))
|
|
|
|
|
|
|
|
|
|
|
|
// 分类统计
|
|
|
|
|
|
freeByCountry := make(map[string]int)
|
|
|
|
|
|
freeByProvider := make(map[string]int)
|
|
|
|
|
|
for _, m := range r.FreeModels {
|
|
|
|
|
|
country := m.ProviderCountry
|
|
|
|
|
|
if country == "unknown" {
|
|
|
|
|
|
country = "国际"
|
2026-05-08 13:49:12 +08:00
|
|
|
|
}
|
2026-05-13 14:42:45 +08:00
|
|
|
|
freeByCountry[country]++
|
|
|
|
|
|
freeByProvider[m.ProviderName]++
|
2026-05-08 13:49:12 +08:00
|
|
|
|
}
|
2026-05-13 14:42:45 +08:00
|
|
|
|
fmt.Fprintf(f, "**按国家分布**: ")
|
|
|
|
|
|
first := true
|
|
|
|
|
|
for country, count := range freeByCountry {
|
|
|
|
|
|
if !first {
|
|
|
|
|
|
fmt.Fprintf(f, ", ")
|
|
|
|
|
|
}
|
|
|
|
|
|
fmt.Fprintf(f, "%s %d个", country, count)
|
|
|
|
|
|
first = false
|
|
|
|
|
|
}
|
|
|
|
|
|
fmt.Fprintf(f, "\n\n")
|
|
|
|
|
|
|
|
|
|
|
|
fmt.Fprintf(f, "**代表性模型(前20个)**:\n\n")
|
|
|
|
|
|
fmt.Fprintf(f, "| 模型 | 厂商 | 国家 | 上下文 |\n")
|
|
|
|
|
|
fmt.Fprintf(f, "|------|------|------|--------|\n")
|
|
|
|
|
|
for _, m := range r.FreeTop20 {
|
|
|
|
|
|
country := m.ProviderCountry
|
|
|
|
|
|
if country == "unknown" {
|
|
|
|
|
|
country = "国际"
|
|
|
|
|
|
}
|
|
|
|
|
|
fmt.Fprintf(f, "| %s | %s | %s | %d |\n", m.Name, m.ProviderName, country, m.ContextLength)
|
|
|
|
|
|
}
|
|
|
|
|
|
if len(r.FreeModels) > 20 {
|
|
|
|
|
|
fmt.Fprintf(f, "| ... | ... | ... | ... |\n")
|
|
|
|
|
|
fmt.Fprintf(f, "\n> 共 %d 个免费模型,以上为前20个代表性模型\n", len(r.FreeModels))
|
|
|
|
|
|
}
|
|
|
|
|
|
fmt.Fprintf(f, "\n")
|
2026-05-08 13:49:12 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-13 14:42:45 +08:00
|
|
|
|
// 国际前5
|
|
|
|
|
|
if len(r.IntlTop5) > 0 {
|
|
|
|
|
|
fmt.Fprintf(f, "## 🌍 国际推荐模型 TOP 5\n\n")
|
|
|
|
|
|
fmt.Fprintf(f, "| 排名 | 模型 | 厂商 | 场景 | 输入(原价) | 输出(原价) | 上下文 |\n")
|
|
|
|
|
|
fmt.Fprintf(f, "|------|------|------|------|-----------|-----------|--------|\n")
|
|
|
|
|
|
for i, m := range r.IntlTop5 {
|
|
|
|
|
|
scene := "对话"
|
|
|
|
|
|
if len(m.SceneTags) > 0 {
|
|
|
|
|
|
scene = string(m.SceneTags[0])
|
|
|
|
|
|
}
|
|
|
|
|
|
fmt.Fprintf(f, "| %d | %s | %s | %s | %s | %s | %d |\n",
|
|
|
|
|
|
i+1, m.Name, m.ProviderName, scene, formatPriceWithCurrency(m.InputPrice, m.Currency), formatPriceWithCurrency(m.OutputPrice, m.Currency), m.ContextLength)
|
|
|
|
|
|
}
|
|
|
|
|
|
fmt.Fprintf(f, "\n")
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 国内前10
|
|
|
|
|
|
if len(r.DomesticTop10) > 0 {
|
|
|
|
|
|
fmt.Fprintf(f, "## 🇨🇳 国内模型 TOP 10\n\n")
|
|
|
|
|
|
fmt.Fprintf(f, "| 排名 | 模型 | 厂商 | 场景 | 输入(CNY) | 输出(CNY) | 上下文 |\n")
|
|
|
|
|
|
fmt.Fprintf(f, "|------|------|------|------|-----------|-----------|--------|\n")
|
|
|
|
|
|
for i, m := range r.DomesticTop10 {
|
|
|
|
|
|
scene := "对话"
|
|
|
|
|
|
if len(m.SceneTags) > 0 {
|
|
|
|
|
|
scene = string(m.SceneTags[0])
|
|
|
|
|
|
}
|
|
|
|
|
|
fmt.Fprintf(f, "| %d | %s | %s | %s | %s | %s | %d |\n",
|
|
|
|
|
|
i+1, m.Name, m.ProviderName, scene, formatDomesticPrice(m.InputPrice, m.Currency), formatDomesticPrice(m.OutputPrice, m.Currency), m.ContextLength)
|
2026-05-08 13:49:12 +08:00
|
|
|
|
}
|
2026-05-13 14:42:45 +08:00
|
|
|
|
fmt.Fprintf(f, "\n")
|
2026-05-08 13:49:12 +08:00
|
|
|
|
} else {
|
2026-05-13 14:42:45 +08:00
|
|
|
|
fmt.Fprintf(f, "## 🇨🇳 国内模型 TOP 10\n\n")
|
|
|
|
|
|
fmt.Fprintf(f, "> ⚠️ 暂无国内厂商数据。当前仅采集了 OpenRouter(国际平台),国内厂商数据将在 Phase 2 接入。\n\n")
|
2026-05-08 13:49:12 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-13 14:42:45 +08:00
|
|
|
|
if len(r.TencentSubscriptionPlans) > 0 {
|
|
|
|
|
|
fmt.Fprintf(f, "## 💳 腾讯云套餐订阅价\n\n")
|
|
|
|
|
|
fmt.Fprintf(f, "> 以下为套餐订阅价,不参与按模型输入/输出单价排行。\n\n")
|
|
|
|
|
|
fmt.Fprintf(f, "| 套餐 | 月费 | 月额度 | 上下文上限 | 覆盖模型 |\n")
|
|
|
|
|
|
fmt.Fprintf(f, "|------|------|--------|------------|----------|\n")
|
|
|
|
|
|
for _, plan := range r.TencentSubscriptionPlans {
|
|
|
|
|
|
fmt.Fprintf(
|
|
|
|
|
|
f,
|
|
|
|
|
|
"| %s | %s | %s | %s | %d 个(%s) |\n",
|
|
|
|
|
|
plan.PlanName,
|
|
|
|
|
|
formatSubscriptionPrice(plan.ListPrice, plan.Currency),
|
|
|
|
|
|
formatSubscriptionQuota(plan.QuotaValue, plan.QuotaUnit),
|
|
|
|
|
|
formatContextWindowCompact(plan.ContextWindow),
|
|
|
|
|
|
plan.ModelCount,
|
|
|
|
|
|
plan.ModelPreview,
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
fmt.Fprintf(f, "\n")
|
|
|
|
|
|
}
|
2026-05-08 13:49:12 +08:00
|
|
|
|
|
2026-05-13 14:42:45 +08:00
|
|
|
|
// 分类模型展示
|
|
|
|
|
|
fmt.Fprintf(f, "## 📊 模型分类概览\n\n")
|
|
|
|
|
|
|
|
|
|
|
|
// 国内模型分类 - 只展示官方平台
|
|
|
|
|
|
if len(r.DomesticTop10) > 0 {
|
|
|
|
|
|
fmt.Fprintf(f, "### 🇨🇳 国内官方平台模型\n\n")
|
|
|
|
|
|
|
|
|
|
|
|
// 按厂商分组
|
|
|
|
|
|
domesticByOperator := make(map[string][]ModelInfo)
|
|
|
|
|
|
for _, m := range r.DomesticTop10 {
|
|
|
|
|
|
if m.OperatorType == "official" || m.OperatorType == "cloud" {
|
|
|
|
|
|
domesticByOperator[m.OperatorName] = append(domesticByOperator[m.OperatorName], m)
|
2026-05-08 13:49:12 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-05-13 14:42:45 +08:00
|
|
|
|
|
|
|
|
|
|
for opName, models := range domesticByOperator {
|
|
|
|
|
|
fmt.Fprintf(f, "**%s** (%d个)\n\n", opName, len(models))
|
|
|
|
|
|
fmt.Fprintf(f, "| 模型 | 场景 | 输入(CNY) | 输出(CNY) | 上下文 |\n")
|
|
|
|
|
|
fmt.Fprintf(f, "|------|------|-----------|-----------|--------|\n")
|
|
|
|
|
|
for _, m := range models {
|
|
|
|
|
|
scene := "对话"
|
|
|
|
|
|
if len(m.SceneTags) > 0 {
|
|
|
|
|
|
scene = string(m.SceneTags[0])
|
|
|
|
|
|
}
|
|
|
|
|
|
fmt.Fprintf(f, "| %s | %s | %s | %s | %d |\n",
|
|
|
|
|
|
m.Name, scene, formatDomesticPrice(m.InputPrice, m.Currency), formatDomesticPrice(m.OutputPrice, m.Currency), m.ContextLength)
|
|
|
|
|
|
}
|
|
|
|
|
|
fmt.Fprintf(f, "\n")
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 代码模型
|
|
|
|
|
|
codeModels := filterByScene(r.FreeModels, SceneCode)
|
|
|
|
|
|
if len(codeModels) > 0 {
|
|
|
|
|
|
fmt.Fprintf(f, "### 💻 代码模型(%d个)\n\n", len(codeModels))
|
|
|
|
|
|
fmt.Fprintf(f, "| 模型 | 厂商 | 输入(原价) | 输出(原价) |\n")
|
|
|
|
|
|
fmt.Fprintf(f, "|------|------|-----------|-----------|\n")
|
|
|
|
|
|
for _, m := range codeModels {
|
|
|
|
|
|
if len(m.Name) > 30 {
|
|
|
|
|
|
m.Name = m.Name[:27] + "..."
|
2026-05-08 13:49:12 +08:00
|
|
|
|
}
|
2026-05-13 14:42:45 +08:00
|
|
|
|
fmt.Fprintf(f, "| %s | %s | %s | %s |\n", m.Name, m.ProviderName, formatPriceWithCurrency(m.InputPrice, m.Currency), formatPriceWithCurrency(m.OutputPrice, m.Currency))
|
2026-05-08 13:49:12 +08:00
|
|
|
|
}
|
2026-05-13 14:42:45 +08:00
|
|
|
|
fmt.Fprintf(f, "\n")
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 推理模型
|
|
|
|
|
|
reasoningModels := filterByScene(r.FreeModels, SceneReasoning)
|
|
|
|
|
|
if len(reasoningModels) > 0 {
|
|
|
|
|
|
fmt.Fprintf(f, "### 🧠 推理模型(%d个)\n\n", len(reasoningModels))
|
|
|
|
|
|
fmt.Fprintf(f, "| 模型 | 厂商 | 输入(原价) | 输出(原价) |\n")
|
|
|
|
|
|
fmt.Fprintf(f, "|------|------|-----------|-----------|\n")
|
|
|
|
|
|
for _, m := range reasoningModels {
|
|
|
|
|
|
if len(m.Name) > 30 {
|
|
|
|
|
|
m.Name = m.Name[:27] + "..."
|
|
|
|
|
|
}
|
|
|
|
|
|
fmt.Fprintf(f, "| %s | %s | %s | %s |\n", m.Name, m.ProviderName, formatPriceWithCurrency(m.InputPrice, m.Currency), formatPriceWithCurrency(m.OutputPrice, m.Currency))
|
|
|
|
|
|
}
|
|
|
|
|
|
fmt.Fprintf(f, "\n")
|
2026-05-08 13:49:12 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-13 14:42:45 +08:00
|
|
|
|
// 视觉/多模态模型
|
|
|
|
|
|
visionModels := filterByScene(r.FreeModels, SceneVision)
|
|
|
|
|
|
if len(visionModels) > 0 {
|
|
|
|
|
|
fmt.Fprintf(f, "### 👁️ 视觉/多模态模型(%d个)\n\n", len(visionModels))
|
|
|
|
|
|
fmt.Fprintf(f, "| 模型 | 厂商 | 输入(原价) | 输出(原价) |\n")
|
|
|
|
|
|
fmt.Fprintf(f, "|------|------|-----------|-----------|\n")
|
|
|
|
|
|
for _, m := range visionModels {
|
|
|
|
|
|
if len(m.Name) > 30 {
|
|
|
|
|
|
m.Name = m.Name[:27] + "..."
|
|
|
|
|
|
}
|
|
|
|
|
|
fmt.Fprintf(f, "| %s | %s | %s | %s |\n", m.Name, m.ProviderName, formatPriceWithCurrency(m.InputPrice, m.Currency), formatPriceWithCurrency(m.OutputPrice, m.Currency))
|
|
|
|
|
|
}
|
|
|
|
|
|
fmt.Fprintf(f, "\n")
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 运营商 - 区分国内和国际
|
|
|
|
|
|
var domesticOps, intlOps []OperatorInfo
|
|
|
|
|
|
for _, op := range r.Operators {
|
|
|
|
|
|
if op.Country == "CN" {
|
|
|
|
|
|
domesticOps = append(domesticOps, op)
|
|
|
|
|
|
} else {
|
|
|
|
|
|
intlOps = append(intlOps, op)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if len(domesticOps) > 0 {
|
|
|
|
|
|
fmt.Fprintf(f, "## 🇨🇳 国内官方平台(%d 家)\n\n", len(domesticOps))
|
|
|
|
|
|
for _, op := range domesticOps {
|
|
|
|
|
|
fmt.Fprintf(f, "- **%s**: %d 个模型,最低 ¥%.2f/MTok\n", op.Name, op.ModelCount, op.MinInputPrice)
|
|
|
|
|
|
}
|
|
|
|
|
|
fmt.Fprintf(f, "\n")
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if len(intlOps) > 0 {
|
|
|
|
|
|
fmt.Fprintf(f, "## ☁️ 国际官方平台(%d 家)\n\n", len(intlOps))
|
|
|
|
|
|
for _, op := range intlOps {
|
|
|
|
|
|
fmt.Fprintf(f, "- **%s**: %d 个模型,最低 $%.2f/MTok\n", op.Name, op.ModelCount, op.MinInputPrice)
|
|
|
|
|
|
}
|
|
|
|
|
|
fmt.Fprintf(f, "\n")
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 中转商
|
|
|
|
|
|
if len(r.Resellers) > 0 {
|
|
|
|
|
|
fmt.Fprintf(f, "## 🔀 中转/聚合平台(%d 家)\n\n", len(r.Resellers))
|
|
|
|
|
|
for _, op := range r.Resellers {
|
|
|
|
|
|
fmt.Fprintf(f, "- **%s**: %d 个模型,最低 $%.2f/MTok\n", op.Name, op.ModelCount, op.MinInputPrice)
|
|
|
|
|
|
}
|
|
|
|
|
|
fmt.Fprintf(f, "\n")
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
fmt.Fprintf(f, "---\n\n📌 **说明**: 本报告由 LLM Intelligence Hub 自动生成。\n")
|
|
|
|
|
|
fmt.Fprintf(f, "- 国际模型价格按 1 USD = 7.25 CNY 换算显示,括号内为原生货币价格\n")
|
|
|
|
|
|
fmt.Fprintf(f, "- 国内模型价格为厂商原生 CNY 定价\n")
|
|
|
|
|
|
fmt.Fprintf(f, "- 数据来源: OpenRouter API + 智谱AI + 百度千帆 + Moonshot + DeepSeek + OpenAI\n")
|
|
|
|
|
|
fmt.Fprintf(f, "\n_生成时间: %s_\n", time.Now().Format(time.RFC3339))
|
2026-05-08 13:49:12 +08:00
|
|
|
|
return nil
|
|
|
|
|
|
}
|
2026-05-13 14:42:45 +08:00
|
|
|
|
|
|
|
|
|
|
func filterByScene(models []ModelInfo, tag SceneTag) []ModelInfo {
|
|
|
|
|
|
var result []ModelInfo
|
|
|
|
|
|
for _, m := range models {
|
|
|
|
|
|
for _, t := range m.SceneTags {
|
|
|
|
|
|
if t == tag {
|
|
|
|
|
|
result = append(result, m)
|
|
|
|
|
|
break
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
return result
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ============ HTML生成(现代化UI) ============
|
|
|
|
|
|
|
|
|
|
|
|
func generateHTMLV3(r *ReportV3, path string) error {
|
|
|
|
|
|
tmpl := `<!DOCTYPE html>
|
|
|
|
|
|
<html lang="zh-CN">
|
|
|
|
|
|
<head>
|
|
|
|
|
|
<meta charset="UTF-8">
|
|
|
|
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
|
|
|
|
<title>LLM Intelligence Hub - {{.Date}}</title>
|
|
|
|
|
|
<style>
|
|
|
|
|
|
:root {
|
|
|
|
|
|
--primary: #6366f1;
|
|
|
|
|
|
--primary-dark: #4f46e5;
|
|
|
|
|
|
--success: #10b981;
|
|
|
|
|
|
--warning: #f59e0b;
|
|
|
|
|
|
--danger: #ef4444;
|
|
|
|
|
|
--info: #3b82f6;
|
|
|
|
|
|
--bg: #f1f5f9;
|
|
|
|
|
|
--card: #ffffff;
|
|
|
|
|
|
--text: #1e293b;
|
|
|
|
|
|
--text-secondary: #64748b;
|
|
|
|
|
|
--border: #e2e8f0;
|
|
|
|
|
|
}
|
|
|
|
|
|
* { margin:0; padding:0; box-sizing:border-box; }
|
|
|
|
|
|
body {
|
|
|
|
|
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
|
|
|
|
background: var(--bg);
|
|
|
|
|
|
color: var(--text);
|
|
|
|
|
|
line-height: 1.6;
|
|
|
|
|
|
}
|
|
|
|
|
|
.container { max-width: 1200px; margin: 0 auto; padding: 20px; }
|
|
|
|
|
|
|
|
|
|
|
|
/* Header */
|
|
|
|
|
|
.header {
|
|
|
|
|
|
background: linear-gradient(135deg, var(--primary), var(--primary-dark));
|
|
|
|
|
|
color: white;
|
|
|
|
|
|
padding: 40px 30px;
|
|
|
|
|
|
border-radius: 16px;
|
|
|
|
|
|
margin-bottom: 24px;
|
|
|
|
|
|
box-shadow: 0 10px 40px rgba(99,102,241,0.3);
|
|
|
|
|
|
}
|
|
|
|
|
|
.header h1 { font-size: 2rem; margin-bottom: 8px; }
|
|
|
|
|
|
.header p { opacity: 0.9; font-size: 1.1rem; }
|
|
|
|
|
|
|
|
|
|
|
|
/* Stats Grid */
|
|
|
|
|
|
.stats-grid {
|
|
|
|
|
|
display: grid;
|
|
|
|
|
|
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
|
|
|
|
|
gap: 16px;
|
|
|
|
|
|
margin-bottom: 24px;
|
|
|
|
|
|
}
|
|
|
|
|
|
.stat-card {
|
|
|
|
|
|
background: var(--card);
|
|
|
|
|
|
border-radius: 12px;
|
|
|
|
|
|
padding: 20px;
|
|
|
|
|
|
box-shadow: 0 2px 8px rgba(0,0,0,0.06);
|
|
|
|
|
|
border-left: 4px solid var(--primary);
|
|
|
|
|
|
}
|
|
|
|
|
|
.stat-card.free { border-left-color: var(--success); }
|
|
|
|
|
|
.stat-card.intl { border-left-color: var(--info); }
|
|
|
|
|
|
.stat-card.domestic { border-left-color: var(--warning); }
|
|
|
|
|
|
.stat-label { font-size: 0.875rem; color: var(--text-secondary); margin-bottom: 4px; }
|
|
|
|
|
|
.stat-value { font-size: 1.75rem; font-weight: 700; color: var(--text); }
|
|
|
|
|
|
|
|
|
|
|
|
/* Section */
|
|
|
|
|
|
.section {
|
|
|
|
|
|
background: var(--card);
|
|
|
|
|
|
border-radius: 12px;
|
|
|
|
|
|
padding: 24px;
|
|
|
|
|
|
margin-bottom: 20px;
|
|
|
|
|
|
box-shadow: 0 2px 8px rgba(0,0,0,0.06);
|
|
|
|
|
|
}
|
|
|
|
|
|
.section h2 {
|
|
|
|
|
|
font-size: 1.25rem;
|
|
|
|
|
|
margin-bottom: 16px;
|
|
|
|
|
|
padding-bottom: 12px;
|
|
|
|
|
|
border-bottom: 2px solid var(--border);
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
gap: 8px;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/* Model Cards */
|
|
|
|
|
|
.model-grid {
|
|
|
|
|
|
display: grid;
|
|
|
|
|
|
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
|
|
|
|
|
gap: 12px;
|
|
|
|
|
|
}
|
|
|
|
|
|
.model-card {
|
|
|
|
|
|
background: var(--bg);
|
|
|
|
|
|
border-radius: 10px;
|
|
|
|
|
|
padding: 16px;
|
|
|
|
|
|
border: 1px solid var(--border);
|
|
|
|
|
|
transition: transform 0.2s, box-shadow 0.2s;
|
|
|
|
|
|
}
|
|
|
|
|
|
.model-card:hover {
|
|
|
|
|
|
transform: translateY(-2px);
|
|
|
|
|
|
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
|
|
|
|
|
|
}
|
|
|
|
|
|
.model-card .name { font-weight: 600; font-size: 0.95rem; margin-bottom: 6px; }
|
|
|
|
|
|
.model-card .provider { font-size: 0.8rem; color: var(--text-secondary); margin-bottom: 8px; }
|
|
|
|
|
|
.model-card .price-row {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
justify-content: space-between;
|
|
|
|
|
|
font-size: 0.85rem;
|
|
|
|
|
|
}
|
|
|
|
|
|
.model-card .price-label { color: var(--text-secondary); }
|
|
|
|
|
|
.model-card .price-value { font-weight: 600; }
|
|
|
|
|
|
.model-card .price-value.free { color: var(--success); }
|
|
|
|
|
|
.model-card .badge {
|
|
|
|
|
|
display: inline-block;
|
|
|
|
|
|
padding: 2px 8px;
|
|
|
|
|
|
border-radius: 4px;
|
|
|
|
|
|
font-size: 0.75rem;
|
|
|
|
|
|
font-weight: 600;
|
|
|
|
|
|
}
|
|
|
|
|
|
.badge.free { background: #d1fae5; color: #065f46; }
|
|
|
|
|
|
.badge.intl { background: #dbeafe; color: #1e40af; }
|
|
|
|
|
|
.badge.domestic { background: #fef3c7; color: #92400e; }
|
|
|
|
|
|
|
|
|
|
|
|
/* Tables */
|
|
|
|
|
|
table { width: 100%; border-collapse: collapse; margin-top: 12px; }
|
|
|
|
|
|
th, td { padding: 12px; text-align: left; border-bottom: 1px solid var(--border); }
|
|
|
|
|
|
th { background: var(--bg); font-weight: 600; font-size: 0.875rem; }
|
|
|
|
|
|
tr:hover { background: #f8fafc; }
|
|
|
|
|
|
|
|
|
|
|
|
/* Alert */
|
|
|
|
|
|
.alert {
|
|
|
|
|
|
background: #fef3c7;
|
|
|
|
|
|
border-left: 4px solid var(--warning);
|
|
|
|
|
|
padding: 12px 16px;
|
|
|
|
|
|
border-radius: 8px;
|
|
|
|
|
|
margin-bottom: 20px;
|
|
|
|
|
|
}
|
|
|
|
|
|
.alert p { color: #92400e; font-size: 0.9rem; }
|
|
|
|
|
|
|
|
|
|
|
|
/* Footer */
|
|
|
|
|
|
.footer {
|
|
|
|
|
|
text-align: center;
|
|
|
|
|
|
color: var(--text-secondary);
|
|
|
|
|
|
padding: 30px;
|
|
|
|
|
|
font-size: 0.875rem;
|
|
|
|
|
|
}
|
|
|
|
|
|
</style>
|
|
|
|
|
|
</head>
|
|
|
|
|
|
<body>
|
|
|
|
|
|
<div class="container">
|
|
|
|
|
|
|
|
|
|
|
|
<div class="header">
|
|
|
|
|
|
<h1>🤖 LLM Intelligence Hub</h1>
|
|
|
|
|
|
<p>每日情报报告 · {{.Date}} · {{.TotalModels}} 模型覆盖</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- 核心指标 -->
|
|
|
|
|
|
<div class="stats-grid">
|
|
|
|
|
|
<div class="stat-card">
|
|
|
|
|
|
<div class="stat-label">模型总数</div>
|
|
|
|
|
|
<div class="stat-value">{{.TotalModels}}</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="stat-card free">
|
|
|
|
|
|
<div class="stat-label">免费模型</div>
|
|
|
|
|
|
<div class="stat-value">{{len .FreeModels}}</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="stat-card intl">
|
|
|
|
|
|
<div class="stat-label">国际模型</div>
|
|
|
|
|
|
<div class="stat-value">{{len .IntlTop5}}</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="stat-card domestic">
|
|
|
|
|
|
<div class="stat-label">国内模型</div>
|
|
|
|
|
|
<div class="stat-value">{{if .HasDomesticData}}{{len .DomesticTop10}}{{else}}0{{end}}</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{{if not .HasDomesticData}}
|
|
|
|
|
|
<div class="alert">
|
|
|
|
|
|
<p>⚠️ 当前仅接入 OpenRouter 数据源,国内厂商 CNY 定价将在 Phase 2 接入。</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
{{end}}
|
|
|
|
|
|
|
|
|
|
|
|
<!-- 免费模型 -->
|
|
|
|
|
|
{{if .FreeModels}}
|
|
|
|
|
|
<div class="section">
|
|
|
|
|
|
<h2>🆓 免费模型({{len .FreeModels}} 个)</h2>
|
|
|
|
|
|
<p style="color: var(--text-secondary); margin-bottom: 12px;">代表性模型(前20个):</p>
|
|
|
|
|
|
<div class="model-grid">
|
|
|
|
|
|
{{range .FreeTop20}}
|
|
|
|
|
|
<div class="model-card">
|
|
|
|
|
|
<div class="name">{{.Name}}</div>
|
|
|
|
|
|
<div class="provider">{{.ProviderName}} <span class="badge {{if eq .ProviderCountry "CN"}}domestic{{else}}intl{{end}}">{{if eq .ProviderCountry "CN"}}国内{{else}}国际{{end}}</span></div>
|
|
|
|
|
|
<div class="price-row">
|
|
|
|
|
|
<span class="price-label">输入</span>
|
|
|
|
|
|
<span class="price-value free">免费</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="price-row">
|
|
|
|
|
|
<span class="price-label">上下文</span>
|
|
|
|
|
|
<span class="price-value">{{.ContextLength}} tokens</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
{{end}}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
{{if gt (len .FreeModels) 20}}
|
|
|
|
|
|
<p style="text-align: center; color: var(--text-secondary); margin-top: 12px;">... 共 {{len .FreeModels}} 个免费模型,以上为前20个</p>
|
|
|
|
|
|
{{end}}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
{{end}}
|
|
|
|
|
|
|
|
|
|
|
|
<!-- 国际 TOP 5 -->
|
|
|
|
|
|
{{if .IntlTop5}}
|
|
|
|
|
|
<div class="section">
|
|
|
|
|
|
<h2>🌍 国际低价模型 TOP 5</h2>
|
|
|
|
|
|
<table>
|
|
|
|
|
|
<tr><th>排名</th><th>模型</th><th>厂商</th><th>输入价格</th><th>输出价格</th><th>上下文</th></tr>
|
|
|
|
|
|
{{range $i, $m := .IntlTop5}}
|
|
|
|
|
|
<tr>
|
|
|
|
|
|
<td>{{add $i 1}}</td>
|
|
|
|
|
|
<td><strong>{{$m.Name}}</strong></td>
|
|
|
|
|
|
<td>{{$m.ProviderName}}</td>
|
|
|
|
|
|
<td>${{printf "%.2f" $m.InputPrice}}</td>
|
|
|
|
|
|
<td>${{printf "%.2f" $m.OutputPrice}}</td>
|
|
|
|
|
|
<td>{{$m.ContextLength}}</td>
|
|
|
|
|
|
</tr>
|
|
|
|
|
|
{{end}}
|
|
|
|
|
|
</table>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
{{end}}
|
|
|
|
|
|
|
|
|
|
|
|
<!-- 国内 TOP 10 -->
|
|
|
|
|
|
{{if .HasDomesticData}}
|
|
|
|
|
|
<div class="section">
|
|
|
|
|
|
<h2>🇨🇳 国内模型 TOP 10</h2>
|
|
|
|
|
|
<table>
|
|
|
|
|
|
<tr><th>排名</th><th>模型</th><th>厂商</th><th>输入价格</th><th>输出价格</th><th>上下文</th></tr>
|
|
|
|
|
|
{{range $i, $m := .DomesticTop10}}
|
|
|
|
|
|
<tr>
|
|
|
|
|
|
<td>{{add $i 1}}</td>
|
|
|
|
|
|
<td><strong>{{$m.Name}}</strong></td>
|
|
|
|
|
|
<td>{{$m.ProviderName}}</td>
|
|
|
|
|
|
<td>${{printf "%.2f" $m.InputPrice}}</td>
|
|
|
|
|
|
<td>${{printf "%.2f" $m.OutputPrice}}</td>
|
|
|
|
|
|
<td>{{$m.ContextLength}}</td>
|
|
|
|
|
|
</tr>
|
|
|
|
|
|
{{end}}
|
|
|
|
|
|
</table>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
{{end}}
|
|
|
|
|
|
|
|
|
|
|
|
{{if .TencentSubscriptionPlans}}
|
|
|
|
|
|
<div class="section">
|
|
|
|
|
|
<h2>💳 腾讯云套餐订阅价</h2>
|
|
|
|
|
|
<p style="color: var(--text-secondary); margin-bottom: 12px;">以下为套餐订阅价,不参与按模型输入/输出单价排行。</p>
|
|
|
|
|
|
<table>
|
|
|
|
|
|
<tr><th>套餐</th><th>月费</th><th>月额度</th><th>上下文上限</th><th>覆盖模型</th></tr>
|
|
|
|
|
|
{{range .TencentSubscriptionPlans}}
|
|
|
|
|
|
<tr>
|
|
|
|
|
|
<td><strong>{{.PlanName}}</strong></td>
|
|
|
|
|
|
<td>{{formatSubscriptionPrice .ListPrice .Currency}}</td>
|
|
|
|
|
|
<td>{{formatSubscriptionQuota .QuotaValue .QuotaUnit}}</td>
|
|
|
|
|
|
<td>{{formatContextWindowCompact .ContextWindow}}</td>
|
|
|
|
|
|
<td>{{.ModelCount}} 个{{if .ModelPreview}}({{.ModelPreview}}){{end}}</td>
|
|
|
|
|
|
</tr>
|
|
|
|
|
|
{{end}}
|
|
|
|
|
|
</table>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
{{end}}
|
|
|
|
|
|
|
|
|
|
|
|
<!-- 运营商 -->
|
|
|
|
|
|
{{if .Operators}}
|
|
|
|
|
|
<div class="section">
|
|
|
|
|
|
<h2>☁️ 云厂商/官方平台({{len .Operators}} 家)</h2>
|
|
|
|
|
|
<table>
|
|
|
|
|
|
<tr><th>平台</th><th>模型数</th><th>最低价格</th><th>平均价格</th></tr>
|
|
|
|
|
|
{{range .Operators}}
|
|
|
|
|
|
<tr><td><strong>{{.Name}}</strong></td><td>{{.ModelCount}}</td><td>${{printf "%.2f" .MinInputPrice}}</td><td>${{printf "%.2f" .AvgInputPrice}}</td></tr>
|
|
|
|
|
|
{{end}}
|
|
|
|
|
|
</table>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
{{end}}
|
|
|
|
|
|
|
|
|
|
|
|
<!-- 中转商 -->
|
|
|
|
|
|
{{if .Resellers}}
|
|
|
|
|
|
<div class="section">
|
|
|
|
|
|
<h2>🔀 中转/聚合平台({{len .Resellers}} 家)</h2>
|
|
|
|
|
|
<table>
|
|
|
|
|
|
<tr><th>平台</th><th>模型数</th><th>最低价格</th><th>平均价格</th></tr>
|
|
|
|
|
|
{{range .Resellers}}
|
|
|
|
|
|
<tr><td><strong>{{.Name}}</strong></td><td>{{.ModelCount}}</td><td>${{printf "%.2f" .MinInputPrice}}</td><td>${{printf "%.2f" .AvgInputPrice}}</td></tr>
|
|
|
|
|
|
{{end}}
|
|
|
|
|
|
</table>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
{{end}}
|
|
|
|
|
|
|
|
|
|
|
|
<div class="footer">
|
|
|
|
|
|
<p>📌 本报告由 LLM Intelligence Hub 自动生成 · {{.Date}}</p>
|
|
|
|
|
|
<p style="margin-top:8px;font-size:0.8rem;">价格单位:USD/1M tokens{{if not .HasCNYData}} · 国内厂商数据待 Phase 2 接入{{end}}</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</body>
|
|
|
|
|
|
</html>`
|
|
|
|
|
|
|
|
|
|
|
|
funcMap := template.FuncMap{
|
|
|
|
|
|
"add": func(a, b int) int { return a + b },
|
|
|
|
|
|
"formatSubscriptionPrice": formatSubscriptionPrice,
|
|
|
|
|
|
"formatSubscriptionQuota": formatSubscriptionQuota,
|
|
|
|
|
|
"formatContextWindowCompact": formatContextWindowCompact,
|
|
|
|
|
|
}
|
|
|
|
|
|
t := template.Must(template.New("report").Funcs(funcMap).Parse(tmpl))
|
|
|
|
|
|
|
|
|
|
|
|
f, err := os.Create(path)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return err
|
|
|
|
|
|
}
|
|
|
|
|
|
defer f.Close()
|
|
|
|
|
|
return t.Execute(f, r)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func saveDailyReportV3(db *sql.DB, r *ReportV3, mdPath string) error {
|
|
|
|
|
|
summary := fmt.Sprintf(
|
|
|
|
|
|
"models=%d free=%d intl=%d domestic=%d",
|
|
|
|
|
|
r.TotalModels,
|
|
|
|
|
|
len(r.FreeModels),
|
|
|
|
|
|
len(r.IntlTop5),
|
|
|
|
|
|
len(r.DomesticTop10),
|
|
|
|
|
|
)
|
|
|
|
|
|
_, err := db.Exec(`
|
|
|
|
|
|
INSERT INTO daily_report (report_date, status, model_count, new_models, free_models, summary_md, output_path, updated_at)
|
|
|
|
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7, NOW())
|
|
|
|
|
|
ON CONFLICT (report_date) DO UPDATE SET
|
|
|
|
|
|
status = EXCLUDED.status,
|
|
|
|
|
|
model_count = EXCLUDED.model_count,
|
|
|
|
|
|
free_models = EXCLUDED.free_models,
|
|
|
|
|
|
summary_md = EXCLUDED.summary_md,
|
|
|
|
|
|
output_path = EXCLUDED.output_path,
|
|
|
|
|
|
updated_at = NOW()
|
|
|
|
|
|
`, r.Date, "generated", r.TotalModels, 0, len(r.FreeModels), summary, mdPath)
|
|
|
|
|
|
return err
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// deriveProviderName 从 modelID 中提取厂商名
|
|
|
|
|
|
func deriveProviderName(modelID string) string {
|
|
|
|
|
|
parts := strings.SplitN(modelID, "/", 2)
|
|
|
|
|
|
if len(parts) == 0 || parts[0] == "" {
|
|
|
|
|
|
return "Unknown"
|
|
|
|
|
|
}
|
|
|
|
|
|
raw := parts[0]
|
|
|
|
|
|
raw = strings.ReplaceAll(raw, "-", " ")
|
|
|
|
|
|
raw = strings.ReplaceAll(raw, "_", " ")
|
|
|
|
|
|
words := strings.Fields(raw)
|
|
|
|
|
|
for i, word := range words {
|
|
|
|
|
|
if len(word) == 0 {
|
|
|
|
|
|
continue
|
|
|
|
|
|
}
|
|
|
|
|
|
words[i] = strings.ToUpper(word[:1]) + strings.ToLower(word[1:])
|
|
|
|
|
|
}
|
|
|
|
|
|
if len(words) == 0 {
|
|
|
|
|
|
return "Unknown"
|
|
|
|
|
|
}
|
|
|
|
|
|
return strings.Join(words, " ")
|
|
|
|
|
|
}
|