web コンテナの土台を追加(HTTP サーバー、TVer API クライアント、htmx の画面枠、Dockerfile・compose.yaml)
This commit is contained in:
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user