哼哼猫文档
本页内容

Golang 代码示例

Go 用标准库的 net/http 加 encoding/json 就能调完这套接口,不需要任何第三方 SDK。

本页给两段:先是一个能直接 go run 的最小例子,再是一份把响应结构体、错误类型和客户端都定义好的封装,后者可以整段复制进项目。

🚀 快速开始

用来验证 Key 与网络是否通,跑通了再往下抄封装那段。例子为了短把几处 err 用 _ 忽略了,搬进生产要逐个处理。真正要改的是 http.Client{}:零值没有超时,目标平台卡住时这个请求会一直挂着,务必设 Timeout(一次提取偶尔要十几秒,别设太短)。

defer resp.Body.Close() 也不能省,而且要把 body 读完,否则这条连接进不了复用池。非 200 的响应体统一是 {message, code?, retryable?}(只有 message 一定有,code 与 retryable 是提取失败的 400 才带的,401、402、422、500 只有 message),这段只原样打印,字段怎么落进结构体见下一节。

package main

import (
	"bytes"
	"encoding/json"
	"fmt"
	"io"
	"net/http"
)

func main() {
	apiURL := "https://api.meowload.net/openapi/v1/extract/post"
	apiKey := "<你的 API Key>"

	// 构造请求
	payload := map[string]string{
		"url": "https://www.youtube.com/watch?v=dQw4w9WgXcQ",
	}

	jsonData, _ := json.Marshal(payload)

	req, _ := http.NewRequest("POST", apiURL, bytes.NewBuffer(jsonData))
	req.Header.Set("Content-Type", "application/json")
	req.Header.Set("Authorization", "Bearer "+apiKey)
	req.Header.Set("Accept-Language", "zh")

	// 发送请求
	client := &http.Client{}
	resp, err := client.Do(req)
	if err != nil {
		fmt.Printf("❌ 网络错误: %v\n", err)
		return
	}
	defer resp.Body.Close()

	// 处理响应
	body, _ := io.ReadAll(resp.Body)

	if resp.StatusCode == 200 {
		fmt.Println("✅ 请求成功")
		fmt.Println(string(body))
	} else {
		// 非 200 的响应体统一是 { message, code?, retryable? }
		fmt.Printf("❌ 请求失败 (%d)\n", resp.StatusCode)
		fmt.Println(string(body))
	}
}

📦 完整封装示例

长期使用的形态:ExtractResponse 描述成功响应,APIError 把 HTTP 状态码和错误体一起带出去,调用方用 errors.As 取出来看 Retryable 决定是否退避重试。可选字段都带 omitempty,用之前要判空——Title 与 Text 都可能是空串,有标题的平台两个都可能有、没标题的只给 Text,展示文案走下面的 Caption() 先取 Title 再回退 Text;Author 是指针,先判 nil 再取。

媒体在 Medias 里,同一媒体的多档清晰度在 Variants 里,IsDefault 为 true 的那档是推荐清晰度;VideoURL 与 AudioURL 同时非空说明是音视频分离的流,要各自下完再合并。Media.Headers 非空时,下载请求必须原样带上这些头。另外例子里解析错误体的 json.Unmarshal 忽略了返回值,万一响应体不是 JSON(例如中间代理返回的 HTML 错误页)会得到一个空 message,生产代码至少要把原始 body 留在日志里。与上一节的裸请求不同,NewClient 这里给 http.Client 设了 60 秒 Timeout——这个客户端活得比单次请求久得多,不设的话一次卡住的提取会永远占着一个 goroutine。

package main

import (
	"bytes"
	"encoding/json"
	"errors"
	"fmt"
	"io"
	"net/http"
	"time"
)

type MeowLoadClient struct {
	APIKey  string
	BaseURL string
	Client  *http.Client
}

type ExtractRequest struct {
	URL string `json:"url"`
}

// Variant 是同一媒体的一个清晰度档或音轨。
// VideoURL 与 AudioURL 同时非空表示音视频分离,需要各自下载后合并。
type Variant struct {
	Quality       int    `json:"quality,omitempty"` // 高度像素,9999 表示原画
	QualityLabel  string `json:"quality_label,omitempty"`
	VideoURL      string `json:"video_url,omitempty"`
	VideoExt      string `json:"video_ext,omitempty"`
	VideoFilesize int64  `json:"video_filesize,omitempty"`
	AudioURL      string `json:"audio_url,omitempty"`
	AudioExt      string `json:"audio_ext,omitempty"`
	AudioFilesize int64  `json:"audio_filesize,omitempty"`
	IsDefault     bool   `json:"is_default,omitempty"`
	LanguageTag   string `json:"language_tag,omitempty"`
	LanguageName  string `json:"language_name,omitempty"`
}

type Media struct {
	MediaType   string            `json:"media_type"`
	ResourceURL string            `json:"resource_url"`
	PreviewURL  string            `json:"preview_url,omitempty"`
	Headers     map[string]string `json:"headers,omitempty"`
	Duration    float64           `json:"duration,omitempty"`
	Variants    []Variant         `json:"variants,omitempty"`
}

type Profile struct {
	Username    string `json:"username"`
	DisplayName string `json:"display_name,omitempty"`
	AvatarURL   string `json:"avatar_url,omitempty"`
}

type ExtractResponse struct {
	Site      string   `json:"site,omitempty"`
	Title     string   `json:"title,omitempty"` // 有标题的平台才有
	Text      string   `json:"text,omitempty"`  // 正文 / 文案
	Medias    []Media  `json:"medias"`
	ID        string   `json:"id,omitempty"`
	CreatedAt string   `json:"created_at,omitempty"`
	Author    *Profile `json:"author,omitempty"`
	PostURL   string   `json:"post_url,omitempty"`
}

// Caption 返回展示用文案:有标题取标题,否则取正文
func (r *ExtractResponse) Caption() string {
	if r.Title != "" {
		return r.Title
	}
	return r.Text
}

// ErrorResponse 是所有非 200 响应的统一形状。Code 与 Retryable 在 400 时一定有
type ErrorResponse struct {
	Message   string `json:"message"`
	Code      string `json:"code,omitempty"`
	Retryable bool   `json:"retryable,omitempty"`
}

// APIError 把 HTTP 状态码与错误体一起带出去,调用方据此决定要不要重试
type APIError struct {
	StatusCode int
	ErrorResponse
}

func (e *APIError) Error() string {
	if e.Code != "" {
		return fmt.Sprintf("请求失败 (%d): %s [code=%s retryable=%t]", e.StatusCode, e.Message, e.Code, e.Retryable)
	}
	return fmt.Sprintf("请求失败 (%d): %s", e.StatusCode, e.Message)
}

// NewClient 创建新的 API 客户端
func NewClient(apiKey string) *MeowLoadClient {
	return &MeowLoadClient{
		APIKey:  apiKey,
		BaseURL: "https://api.meowload.net/openapi/v1",
		// 客户端活得比单次请求久得多,零值 http.Client 没有超时,卡住的提取会一直占着 goroutine
		Client:  &http.Client{Timeout: 60 * time.Second},
	}
}

// ExtractPost 提取单个帖子
func (c *MeowLoadClient) ExtractPost(url string) (*ExtractResponse, error) {
	endpoint := c.BaseURL + "/extract/post"

	// 构造请求
	reqBody := ExtractRequest{URL: url}
	jsonData, err := json.Marshal(reqBody)
	if err != nil {
		return nil, fmt.Errorf("JSON 序列化失败: %w", err)
	}

	// 创建 HTTP 请求
	req, err := http.NewRequest("POST", endpoint, bytes.NewBuffer(jsonData))
	if err != nil {
		return nil, fmt.Errorf("创建请求失败: %w", err)
	}

	// 设置请求头
	req.Header.Set("Content-Type", "application/json")
	req.Header.Set("Authorization", "Bearer "+c.APIKey)
	req.Header.Set("Accept-Language", "zh")

	// 发送请求
	resp, err := c.Client.Do(req)
	if err != nil {
		return nil, fmt.Errorf("发送请求失败: %w", err)
	}
	defer resp.Body.Close()

	// 读取响应
	body, err := io.ReadAll(resp.Body)
	if err != nil {
		return nil, fmt.Errorf("读取响应失败: %w", err)
	}

	// 处理错误响应
	if resp.StatusCode != 200 {
		apiErr := &APIError{StatusCode: resp.StatusCode}
		json.Unmarshal(body, &apiErr.ErrorResponse)
		return nil, apiErr
	}

	// 解析成功响应
	var result ExtractResponse
	if err := json.Unmarshal(body, &result); err != nil {
		return nil, fmt.Errorf("解析响应失败: %w", err)
	}

	return &result, nil
}

func main() {
	// 创建客户端
	client := NewClient("<你的 API Key>")

	// 提取帖子
	result, err := client.ExtractPost("https://www.bilibili.com/video/BV1sG4y1p7TA/")
	if err != nil {
		fmt.Printf("❌ 错误: %v\n", err)
		var apiErr *APIError
		if errors.As(err, &apiErr) && apiErr.Retryable {
			fmt.Println("   临时失败,可稍后重试")
		}
		return
	}

	fmt.Println("✅ 提取成功")
	fmt.Printf("平台: %s\n", result.Site)
	fmt.Printf("文案: %s\n", result.Caption())
	if result.Author != nil {
		fmt.Printf("作者: %s\n", result.Author.Username)
	}
	fmt.Printf("媒体数量: %d\n", len(result.Medias))

	for idx, media := range result.Medias {
		fmt.Printf("\n媒体 %d:\n", idx+1)
		fmt.Printf("  类型: %s\n", media.MediaType)
		fmt.Printf("  地址: %s\n", media.ResourceURL)
		for _, v := range media.Variants {
			split := ""
			if v.VideoURL != "" && v.AudioURL != "" {
				split = "(需合并)"
			}
			fmt.Printf("  - %s%s\n", v.QualityLabel, split)
		}
	}
}

接下来这几页会用得上: