web コンテナの土台を追加(HTTP サーバー、TVer API クライアント、htmx の画面枠、Dockerfile・compose.yaml)

This commit is contained in:
2026-10-03 17:01:50 +09:00
parent 4ad0d621e1
commit 5125614b93
15 changed files with 677 additions and 5 deletions
+154
View File
@@ -0,0 +1,154 @@
package tver
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
)
const DefaultBaseURL = "https://platform-api.tver.jp"
type Session struct {
PlatformUID string `json:"platform_uid"`
PlatformToken string `json:"platform_token"`
}
type Episode struct {
ID string `json:"id"`
SeriesID string `json:"seriesID"`
SeriesTitle string `json:"seriesTitle"`
Title string `json:"title"`
BroadcasterName string `json:"broadcasterName"`
Duration int64 `json:"duration"`
EndAt int64 `json:"endAt"`
ThumbnailPath string `json:"thumbnailPath"`
BroadcastDateLabel string `json:"broadcastDateLabel"`
}
type Client struct {
baseURL *url.URL
httpClient *http.Client
}
// NewClient はテスト用に API の接続先と HTTP クライアントを差し替えられる。
// 接続先が空、またはクライアントが nil の場合は本番用の既定値を使う。
func NewClient(baseURL string, httpClient *http.Client) (*Client, error) {
if baseURL == "" {
baseURL = DefaultBaseURL
}
parsed, err := url.Parse(baseURL)
if err != nil || (parsed.Scheme != "https" && parsed.Scheme != "http") || parsed.Host == "" || parsed.RawQuery != "" || parsed.Fragment != "" {
return nil, fmt.Errorf("invalid TVer API base URL")
}
if httpClient == nil {
httpClient = &http.Client{Timeout: 15 * time.Second}
}
return &Client{baseURL: parsed, httpClient: httpClient}, nil
}
func (c *Client) CreateSession(ctx context.Context) (Session, error) {
endpoint := c.baseURL.ResolveReference(&url.URL{Path: "/v2/api/platform_users/browser/create"})
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint.String(), strings.NewReader("device_type=pc"))
if err != nil {
return Session{}, fmt.Errorf("create session request: %w", err)
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
var response struct {
Code int `json:"code"`
Message string `json:"message"`
Result Session `json:"result"`
}
if err := c.getJSON(req, &response); err != nil {
return Session{}, fmt.Errorf("create session: %w", err)
}
if response.Code != 0 || response.Result.PlatformUID == "" || response.Result.PlatformToken == "" {
return Session{}, fmt.Errorf("create session: invalid API result (code %d)", response.Code)
}
return response.Result, nil
}
func (c *Client) ListNewer(ctx context.Context, category string, session Session) ([]Episode, error) {
if category != "drama" && category != "variety" {
return nil, fmt.Errorf("unsupported category %q", category)
}
if session.PlatformUID == "" || session.PlatformToken == "" {
return nil, errors.New("missing TVer session credentials")
}
endpoint := c.baseURL.ResolveReference(&url.URL{Path: "/service/api/v1/callNewerDetail/" + category})
query := endpoint.Query()
query.Set("platform_uid", session.PlatformUID)
query.Set("platform_token", session.PlatformToken)
endpoint.RawQuery = query.Encode()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint.String(), nil)
if err != nil {
return nil, fmt.Errorf("newer list request: %w", err)
}
req.Header.Set("x-tver-platform-type", "web")
req.Header.Set("Origin", "https://tver.jp")
req.Header.Set("Referer", "https://tver.jp/")
var response struct {
Code int `json:"code"`
Result struct {
Contents *struct {
Contents []struct {
Type string `json:"type"`
Content Episode `json:"content"`
} `json:"contents"`
} `json:"contents"`
} `json:"result"`
}
if err := c.getJSON(req, &response); err != nil {
return nil, fmt.Errorf("newer list: %w", err)
}
if response.Code != 0 {
return nil, fmt.Errorf("newer list: API code %d", response.Code)
}
if response.Result.Contents == nil || response.Result.Contents.Contents == nil {
return nil, errors.New("newer list: missing contents")
}
var episodes []Episode
for _, item := range response.Result.Contents.Contents {
if item.Type != "episode" {
continue
}
if item.Content.ID == "" {
return nil, errors.New("newer list: episode without id")
}
episodes = append(episodes, item.Content)
}
return episodes, nil
}
func (c *Client) FetchNewer(ctx context.Context, category string) ([]Episode, error) {
session, err := c.CreateSession(ctx)
if err != nil {
return nil, err
}
return c.ListNewer(ctx, category, session)
}
func (c *Client) getJSON(req *http.Request, dst any) error {
response, err := c.httpClient.Do(req)
if err != nil {
// url.Error には platform_token を含むリクエスト URL が入るため、内部のエラーだけを返す。
var urlErr *url.Error
if errors.As(err, &urlErr) {
err = urlErr.Err
}
return fmt.Errorf("request failed: %w", err)
}
defer response.Body.Close()
if response.StatusCode < 200 || response.StatusCode >= 300 {
return fmt.Errorf("HTTP %d", response.StatusCode)
}
if err := json.NewDecoder(io.LimitReader(response.Body, 8<<20)).Decode(dst); err != nil {
return fmt.Errorf("decode JSON: %w", err)
}
return nil
}
+213
View File
@@ -0,0 +1,213 @@
package tver
import (
"context"
"fmt"
"net/http"
"net/http/httptest"
"net/url"
"os"
"strings"
"testing"
)
type localOnlyTransport struct {
host string
base http.RoundTripper
}
func (tr localOnlyTransport) RoundTrip(req *http.Request) (*http.Response, error) {
if req.URL.Scheme != "http" || req.URL.Host != tr.host {
return nil, fmt.Errorf("test attempted external request to %s", req.URL.Host)
}
return tr.base.RoundTrip(req)
}
func testClient(t *testing.T, handler http.HandlerFunc) *Client {
t.Helper()
server := httptest.NewServer(handler)
t.Cleanup(server.Close)
serverURL, err := url.Parse(server.URL)
if err != nil {
t.Fatal(err)
}
httpClient := server.Client()
baseTransport := httpClient.Transport
if baseTransport == nil {
baseTransport = http.DefaultTransport
}
httpClient.Transport = localOnlyTransport{host: serverURL.Host, base: baseTransport}
client, err := NewClient(server.URL, httpClient)
if err != nil {
t.Fatal(err)
}
return client
}
func TestTestClientRejectsExternalRequests(t *testing.T) {
client := testClient(t, func(w http.ResponseWriter, r *http.Request) {
t.Errorf("unexpected local request to %s", r.URL)
})
_, err := client.httpClient.Get(DefaultBaseURL)
if err == nil || !strings.Contains(err.Error(), "test attempted external request") {
t.Errorf("external request error = %v", err)
}
}
func TestFetchNewerFromRecordedResponse(t *testing.T) {
fixture, err := os.ReadFile("testdata/newer_drama.json")
if err != nil {
t.Fatal(err)
}
var sessionCalls, newerCalls int
client := testClient(t, func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/v2/api/platform_users/browser/create":
sessionCalls++
if r.Method != http.MethodPost || r.Header.Get("Content-Type") != "application/x-www-form-urlencoded" {
t.Errorf("session request method/Content-Type: %s %s", r.Method, r.Header.Get("Content-Type"))
}
if err := r.ParseForm(); err != nil || r.PostForm.Get("device_type") != "pc" {
t.Errorf("session form: %v, %v", r.PostForm, err)
}
_, _ = w.Write([]byte(`{"code":0,"result":{"platform_uid":"uid-test","platform_token":"token-test"}}`))
case "/service/api/v1/callNewerDetail/drama":
newerCalls++
if r.Method != http.MethodGet || r.URL.Query().Get("platform_uid") != "uid-test" || r.URL.Query().Get("platform_token") != "token-test" {
t.Errorf("newer request method/query: %s %v", r.Method, r.URL.Query())
}
for name, want := range map[string]string{"x-tver-platform-type": "web", "Origin": "https://tver.jp", "Referer": "https://tver.jp/"} {
if got := r.Header.Get(name); got != want {
t.Errorf("header %s = %q; want %q", name, got, want)
}
}
_, _ = w.Write(fixture)
default:
t.Errorf("unexpected path %s", r.URL.Path)
http.NotFound(w, r)
}
})
episodes, err := client.FetchNewer(context.Background(), "drama")
if err != nil {
t.Fatal(err)
}
if sessionCalls != 1 || newerCalls != 1 {
t.Fatalf("calls: session=%d newer=%d", sessionCalls, newerCalls)
}
if len(episodes) != 3 {
t.Fatalf("episodes = %d; want 3", len(episodes))
}
want := Episode{
ID: "eps0z5761a",
SeriesID: "sr0lhdrtdb",
SeriesTitle: "韓国ドラマ「良いが悪い、ドンジェ」",
Title: "#10(最終話)",
BroadcasterName: "BS12トゥエルビ",
Duration: 2712,
EndAt: 1792209540,
ThumbnailPath: "/images/content/thumbnail/episode/xlarge/eps0z5761a.jpg?v=b45ddb51",
BroadcastDateLabel: "10月3日(土)放送分",
}
if episodes[0] != want {
t.Errorf("first episode = %+v; want %+v", episodes[0], want)
}
if episodes[2].Duration != 60 || episodes[2].BroadcastDateLabel != "" {
t.Errorf("third episode = %+v", episodes[2])
}
}
func TestListNewerVarietySkipsOtherTypes(t *testing.T) {
var calls int
client := testClient(t, func(w http.ResponseWriter, r *http.Request) {
calls++
if r.URL.Path != "/service/api/v1/callNewerDetail/variety" {
t.Errorf("path = %s", r.URL.Path)
}
if r.Method != http.MethodGet || r.URL.Query().Get("platform_uid") != "u" || r.URL.Query().Get("platform_token") != "t" {
t.Errorf("variety request method/query: %s %v", r.Method, r.URL.Query())
}
for name, want := range map[string]string{"x-tver-platform-type": "web", "Origin": "https://tver.jp", "Referer": "https://tver.jp/"} {
if got := r.Header.Get(name); got != want {
t.Errorf("variety header %s = %q; want %q", name, got, want)
}
}
_, _ = w.Write([]byte(`{"code":0,"result":{"contents":{"contents":[{"type":"series","content":{"id":"series-1"}},{"type":"episode","content":{"id":"episode-1","duration":42}}]}}}`))
})
episodes, err := client.ListNewer(context.Background(), "variety", Session{PlatformUID: "u", PlatformToken: "t"})
if err != nil {
t.Fatal(err)
}
if len(episodes) != 1 || episodes[0].ID != "episode-1" || episodes[0].Duration != 42 {
t.Errorf("episodes = %+v", episodes)
}
if calls != 1 {
t.Errorf("variety calls = %d; want 1", calls)
}
}
func TestFetchNewerErrors(t *testing.T) {
tests := []struct {
name string
sessionStatus int
sessionBody string
newerStatus int
newerBody string
want string
}{
{"session HTTP error", 503, "", 200, "", "create session: HTTP 503"},
{"session invalid JSON", 200, "{", 200, "", "create session: decode JSON"},
{"session API error", 200, `{"code":7,"result":{"platform_uid":"u","platform_token":"t"}}`, 200, "", "create session: invalid API result"},
{"session missing credentials", 200, `{"code":0,"result":{}}`, 200, "", "create session: invalid API result"},
{"session missing token", 200, `{"code":0,"result":{"platform_uid":"u"}}`, 200, "", "create session: invalid API result"},
{"session missing uid", 200, `{"code":0,"result":{"platform_token":"t"}}`, 200, "", "create session: invalid API result"},
{"newer HTTP error", 200, `{"code":0,"result":{"platform_uid":"u","platform_token":"t"}}`, 502, "", "newer list: HTTP 502"},
{"newer invalid JSON", 200, `{"code":0,"result":{"platform_uid":"u","platform_token":"t"}}`, 200, "{", "newer list: decode JSON"},
{"newer API error", 200, `{"code":0,"result":{"platform_uid":"u","platform_token":"t"}}`, 200, `{"code":7}`, "newer list: API code 7"},
{"newer missing contents", 200, `{"code":0,"result":{"platform_uid":"u","platform_token":"t"}}`, 200, `{"code":0,"result":{}}`, "newer list: missing contents"},
{"newer missing items", 200, `{"code":0,"result":{"platform_uid":"u","platform_token":"t"}}`, 200, `{"code":0,"result":{"contents":{}}}`, "newer list: missing contents"},
{"episode missing id", 200, `{"code":0,"result":{"platform_uid":"u","platform_token":"t"}}`, 200, `{"code":0,"result":{"contents":{"contents":[{"type":"episode","content":{"title":"untitled"}}]}}}`, "newer list: episode without id"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var sessionCalls, newerCalls int
client := testClient(t, func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/v2/api/platform_users/browser/create":
sessionCalls++
w.WriteHeader(tt.sessionStatus)
_, _ = w.Write([]byte(tt.sessionBody))
case "/service/api/v1/callNewerDetail/drama":
newerCalls++
w.WriteHeader(tt.newerStatus)
_, _ = w.Write([]byte(tt.newerBody))
default:
t.Errorf("unexpected path %s", r.URL.Path)
http.NotFound(w, r)
}
})
_, err := client.FetchNewer(context.Background(), "drama")
if err == nil || !strings.Contains(err.Error(), tt.want) {
t.Errorf("error = %v; want %q", err, tt.want)
}
wantNewerCalls := 1
if strings.HasPrefix(tt.name, "session ") {
wantNewerCalls = 0
}
if sessionCalls != 1 || newerCalls != wantNewerCalls {
t.Errorf("calls: session=%d newer=%d; want session=1 newer=%d", sessionCalls, newerCalls, wantNewerCalls)
}
})
}
}
func TestListNewerRejectsInvalidInput(t *testing.T) {
client := testClient(t, func(w http.ResponseWriter, r *http.Request) {
t.Errorf("unexpected request to %s", r.URL)
})
if _, err := client.ListNewer(context.Background(), "news", Session{PlatformUID: "u", PlatformToken: "t"}); err == nil {
t.Error("unsupported category accepted")
}
if _, err := client.ListNewer(context.Background(), "drama", Session{}); err == nil {
t.Error("empty session accepted")
}
}
+91
View File
@@ -0,0 +1,91 @@
{
"api_version": "",
"code": 0,
"message": "",
"type": "hasharray",
"result": {
"contents": {
"type": "newer",
"content": {
"id": "drama",
"version": 1,
"title": "ドラマ"
},
"thumbnailType": "horizontal",
"contents": [
{
"type": "episode",
"content": {
"id": "eps0z5761a",
"version": 4,
"title": "#10(最終話)",
"seriesID": "sr0lhdrtdb",
"duration": 2712,
"endAt": 1792209540,
"broadcastDateLabel": "10月3日(土)放送分",
"isNHKContent": false,
"isSubtitle": false,
"ribbonID": 0,
"seriesTitle": "韓国ドラマ「良いが悪い、ドンジェ」",
"isAvailable": true,
"broadcasterName": "BS12トゥエルビ",
"productionProviderName": "BS12 トゥエルビ",
"isEndingSoon": false,
"thumbnailPath": "/images/content/thumbnail/episode/xlarge/eps0z5761a.jpg?v=b45ddb51",
"isChannelDefaultSeries": false
},
"startAt": 1791007200,
"endAt": 1792209540
},
{
"type": "episode",
"content": {
"id": "epo4tfi10u",
"version": 4,
"title": "#9",
"seriesID": "sr0lhdrtdb",
"duration": 2712,
"endAt": 1792209540,
"broadcastDateLabel": "10月3日(土)放送分",
"isNHKContent": false,
"isSubtitle": false,
"ribbonID": 0,
"seriesTitle": "韓国ドラマ「良いが悪い、ドンジェ」",
"isAvailable": true,
"broadcasterName": "BS12トゥエルビ",
"productionProviderName": "BS12 トゥエルビ",
"isEndingSoon": false,
"thumbnailPath": "/images/content/thumbnail/episode/xlarge/epo4tfi10u.jpg?v=2d8ff444",
"isChannelDefaultSeries": false
},
"startAt": 1791007200,
"endAt": 1792209540
},
{
"type": "episode",
"content": {
"id": "ep8cxuessg",
"version": 6,
"title": "【予告】10月12日(月)24時24分 ~ 放送予定 60秒PR!",
"seriesID": "srne6nwihe",
"duration": 60,
"endAt": 1800282240,
"broadcastDateLabel": "",
"isNHKContent": false,
"isSubtitle": false,
"ribbonID": 1,
"seriesTitle": "十六夜膳房 -いざよいぜんぼう-",
"isAvailable": true,
"broadcasterName": "日テレ",
"productionProviderName": "日テレ",
"isEndingSoon": false,
"thumbnailPath": "/images/content/thumbnail/episode/xlarge/ep8cxuessg.jpg?v=58408db2",
"isChannelDefaultSeries": false
},
"startAt": 1791007200,
"endAt": 1800282240
}
]
}
}
}