From 67a7cfe1fb75f576737ee0ce2f6cd4077758ff3e Mon Sep 17 00:00:00 2001 From: xxxsen Date: Sun, 19 Apr 2026 18:26:20 +0800 Subject: [PATCH 1/8] test: tidy sample identifiers and fixture labels --- README.md | 4 +- internal/capture/capture_test.go | 6 +- internal/job/service_test.go | 18 ++-- internal/jobdef/conflict_test.go | 2 + internal/number/fuzz_test.go | 4 +- internal/number/number_test.go | 4 +- .../handler/tag_padder_handler_test.go | 4 +- internal/scanner/scanner_test.go | 2 +- internal/searcher/plugin/yaml/plugin_test.go | 84 +++++++++---------- .../library-shell/__tests__/utils.test.ts | 4 +- 10 files changed, 67 insertions(+), 65 deletions(-) diff --git a/README.md b/README.md index 2f9a1846..09773e52 100644 --- a/README.md +++ b/README.md @@ -307,8 +307,8 @@ chains: | 后缀 | 举例 | 说明 | |---|---|---| | `-CD{Number}` | `-CD1` | 多 CD 场景下指定当前影片对应 CD ID(从 1 开始) | -| `-C` | `-` | 添加“字幕”分类并为封面添加水印 | -| `-4K` | `-` | 添加“4K”分类并为封面添加水印 | +| `-C` | `-` | 标记为含字幕轨版本,添加相应分类并为封面附加水印 | +| `-4K` | `-` | 添加“4K”分类并为封面附加水印 | | `-8K` | `-` | 添加 8K 水印 | | `-VR` | `-` | 添加 VR 水印 | diff --git a/internal/capture/capture_test.go b/internal/capture/capture_test.go index c9272c17..e8a1d40d 100644 --- a/internal/capture/capture_test.go +++ b/internal/capture/capture_test.go @@ -249,14 +249,14 @@ func TestResolveFileContext(t *testing.T) { name: "uses cleaner derived fields for preferred number", cleaner: &staticCleaner{ normalized: "ABC-123", - category: "HEYZO", + category: "DEMO", categoryMatched: true, uncensor: true, uncensorMatched: true, }, file: "ignored.mp4", - preferredNumber: "HEYZO-0040", - wantNumber: "HEYZO-0040", + preferredNumber: "DEMO-0040", + wantNumber: "DEMO-0040", }, { name: "cleaner returns error", diff --git a/internal/job/service_test.go b/internal/job/service_test.go index ed94d1e1..aa929b3a 100644 --- a/internal/job/service_test.go +++ b/internal/job/service_test.go @@ -213,17 +213,17 @@ func TestServiceRunProcessesJobsSequentially(t *testing.T) { func TestServiceResolveJobSourcePathFallsBackToRenamedNumberFile(t *testing.T) { svc, repo := newTestService(t) dir := t.TempDir() - newFile := filepath.Join(dir, "HEYZO-0040.mp4") + newFile := filepath.Join(dir, "DEMO-0040.mp4") require.NoError(t, os.WriteFile(newFile, []byte("x"), 0o600)) jobID := insertJobWithInput(t, repo, repository.UpsertJobInput{ - FileName: "HEYZO-040.mp4", + FileName: "DEMO-040.mp4", FileExt: ".mp4", - RelPath: "HEYZO-040.mp4", - AbsPath: filepath.Join(dir, "HEYZO-040.mp4"), - Number: "HEYZO-0040", - RawNumber: "HEYZO-040", - CleanedNumber: "HEYZO-0040", + RelPath: "DEMO-040.mp4", + AbsPath: filepath.Join(dir, "DEMO-040.mp4"), + Number: "DEMO-0040", + RawNumber: "DEMO-040", + CleanedNumber: "DEMO-0040", NumberSource: "manual", NumberCleanStatus: "success", NumberCleanConfidence: "high", @@ -243,8 +243,8 @@ func TestServiceResolveJobSourcePathFallsBackToRenamedNumberFile(t *testing.T) { require.NoError(t, err) require.NotNil(t, got) require.Equal(t, newFile, got.AbsPath) - require.Equal(t, "HEYZO-0040.mp4", got.FileName) - require.Equal(t, "HEYZO-0040.mp4", got.RelPath) + require.Equal(t, "DEMO-0040.mp4", got.FileName) + require.Equal(t, "DEMO-0040.mp4", got.RelPath) } type sequentialTestSearcher struct { diff --git a/internal/jobdef/conflict_test.go b/internal/jobdef/conflict_test.go index 0ae0da56..87b5125a 100644 --- a/internal/jobdef/conflict_test.go +++ b/internal/jobdef/conflict_test.go @@ -61,6 +61,8 @@ func TestBuildConflictKey_EmptyExtFileNameWithoutExt(t *testing.T) { func TestBuildConflictKey_ExtWithLeadingTrailingSpaces(t *testing.T) { t.Parallel() + // 典型含多段连字符的历史命名格式, 保留此形态以覆盖 Parse 对 "prefix-suffix-id" + // 的切分边界。 parsed, err := number.Parse("fc2-ppv-1234567") require.NoError(t, err) wantBase := strings.ToUpper(parsed.GenerateFileName()) diff --git a/internal/number/fuzz_test.go b/internal/number/fuzz_test.go index f73aba16..b6b130f2 100644 --- a/internal/number/fuzz_test.go +++ b/internal/number/fuzz_test.go @@ -18,7 +18,7 @@ import ( func FuzzParse(f *testing.F) { seeds := []string{ "", - "HEYZO-3332", + "DEMO-3332", "052624_01", "052624_01-CD2", "abc-leak-c", @@ -64,7 +64,7 @@ func FuzzParse(f *testing.F) { // 应导致下游 Parse 接收越界切片或 panic。 func FuzzParseWithFileName(f *testing.F) { seeds := []string{ - "HEYZO-3332.mp4", + "DEMO-3332.mp4", "a.mp4", ".mp4", "noext", diff --git a/internal/number/number_test.go b/internal/number/number_test.go index 9e51a68b..fe4e3f1e 100644 --- a/internal/number/number_test.go +++ b/internal/number/number_test.go @@ -12,8 +12,8 @@ import ( func TestNumber(t *testing.T) { checkList := map[string]*Number{ - "HEYZO-3332.mp4": { - numberID: "HEYZO-3332", + "DEMO-3332.mp4": { + numberID: "DEMO-3332", }, "052624_01.mp4": { numberID: "052624_01", diff --git a/internal/processor/handler/tag_padder_handler_test.go b/internal/processor/handler/tag_padder_handler_test.go index b79d7d3e..2c4d33d5 100644 --- a/internal/processor/handler/tag_padder_handler_test.go +++ b/internal/processor/handler/tag_padder_handler_test.go @@ -35,9 +35,9 @@ func TestTagPadderHandler(t *testing.T) { }, { name: "underscore separator", - numberID: "HEYZO_1234", + numberID: "PREFIX_1234", genres: nil, - wantPrefix: "HEYZO", + wantPrefix: "PREFIX", wantHas: true, }, } diff --git a/internal/scanner/scanner_test.go b/internal/scanner/scanner_test.go index b4d02a3d..a233abe5 100644 --- a/internal/scanner/scanner_test.go +++ b/internal/scanner/scanner_test.go @@ -166,7 +166,7 @@ func TestScanRejectsReentryWhileRunning(t *testing.T) { require.NoError(t, sqlite.Close()) }) - filePath := filepath.Join(scanDir, "HEYZO-0040.mp4") + filePath := filepath.Join(scanDir, "DEMO-0040.mp4") require.NoError(t, os.WriteFile(filePath, []byte("x"), 0o600)) cleaner := &blockingCleaner{ diff --git a/internal/searcher/plugin/yaml/plugin_test.go b/internal/searcher/plugin/yaml/plugin_test.go index 30bb1d27..dc1b9002 100644 --- a/internal/searcher/plugin/yaml/plugin_test.go +++ b/internal/searcher/plugin/yaml/plugin_test.go @@ -89,7 +89,7 @@ scrape: require.False(t, ok) } -func TestYAML_Jav321_OneStep(t *testing.T) { +func TestYAML_OneStep_PostForm(t *testing.T) { var baseURL string srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if strings.HasPrefix(r.URL.Path, "/img/") { @@ -119,13 +119,13 @@ func TestYAML_Jav321_OneStep(t *testing.T) { - 品番: ABC-123 - 出演者AliceBob - 配信開始日: 2024-01-02 - 収録時間: 120 分钟 - メーカーStudio A - シリーズ: Series X - ジャンルDramaTag2 + Number: ABC-123 + CastAliceBob + Release Date: 2024-01-02 + Runtime: 120 分钟 + StudioStudio A + Series: Series X + GenreDramaTag2

`, "{{HOST}}", baseURL))) @@ -134,7 +134,7 @@ func TestYAML_Jav321_OneStep(t *testing.T) { baseURL = srv.URL plg := mustPluginFromYAML(t, strings.ReplaceAll(oneStepFixtureYAML(), "https://fixture.example", srv.URL)) - meta := mustSearch(t, "jav321", plg, srv.Client(), "ABC-123") + meta := mustSearch(t, "demo-onestep", plg, srv.Client(), "ABC-123") require.Equal(t, "ABC-123", meta.Number) require.Equal(t, "Sample Title", meta.Title) require.Equal(t, []string{"Alice", "Bob"}, meta.Actors) @@ -150,7 +150,7 @@ func TestYAML_Jav321_OneStep(t *testing.T) { require.EqualValues(t, 120*60, meta.Duration) } -func TestYAML_JavDB_TwoStep(t *testing.T) { +func TestYAML_TwoStep_HTMLList(t *testing.T) { var baseURL string srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if strings.HasPrefix(r.URL.Path, "/img/") { @@ -180,14 +180,14 @@ func TestYAML_JavDB_TwoStep(t *testing.T) { _, _ = w.Write([]byte(strings.ReplaceAll(` -

JavDB Title

-
演員AliceBob
-
日期2024-05-06
-
時長150 分钟
-
片商Studio J
-
系列Series J
-
類別DramaAction
-
+

Sample Twostep Title

+
CastAliceBob
+
Release Date2024-05-06
+
Runtime150 分钟
+
StudioStudio J
+
SeriesSeries J
+
GenreDramaAction
+
`, "{{HOST}}", baseURL))) default: @@ -198,21 +198,21 @@ func TestYAML_JavDB_TwoStep(t *testing.T) { baseURL = srv.URL plg := mustPluginFromYAML(t, strings.ReplaceAll(twoStepFixtureYAML(), "https://fixture.example", srv.URL)) - meta := mustSearch(t, "javdb", plg, srv.Client(), "ABC-123") + meta := mustSearch(t, "demo-twostep", plg, srv.Client(), "ABC-123") require.Equal(t, "ABC-123", meta.Number) - require.Equal(t, "JavDB Title", meta.Title) + require.Equal(t, "Sample Twostep Title", meta.Title) require.Equal(t, []string{"Alice", "Bob"}, meta.Actors) require.Equal(t, "Studio J", meta.Studio) require.Equal(t, "Series J", meta.Series) require.Equal(t, []string{"Drama", "Action"}, meta.Genres) - require.Equal(t, srv.URL+"/img/javdb-cover.jpg", meta.Cover.Name) + require.Equal(t, srv.URL+"/img/sample-cover.jpg", meta.Cover.Name) require.Len(t, meta.SampleImages, 2) require.Equal(t, enum.MetaLangJa, meta.TitleLang) require.NotZero(t, meta.ReleaseDate) require.EqualValues(t, 150*60, meta.Duration) } -func TestYAML_Airav_JSON(t *testing.T) { +func TestYAML_JSON_OneStep(t *testing.T) { var baseURL string srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if strings.HasPrefix(r.URL.Path, "/img/") { @@ -221,7 +221,7 @@ func TestYAML_Airav_JSON(t *testing.T) { return } require.Equal(t, http.MethodGet, r.Method) - require.Equal(t, "/api/video/barcode/ABC-123", r.URL.Path) + require.Equal(t, "/api/video/code/ABC-123", r.URL.Path) require.Equal(t, "zh-TW", r.URL.Query().Get("lng")) w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(strings.ReplaceAll(`{ @@ -229,8 +229,8 @@ func TestYAML_Airav_JSON(t *testing.T) { "status": "ok", "result": { "barcode": "ABC-123", - "name": "Airav Title", - "description": "Airav Plot", + "name": "Sample JSON Title", + "description": "Sample JSON Plot", "img_url": "{{HOST}}/img/cover.jpg", "publish_date": "2024-05-06", "actors": [{"name": "Alice"}, {"name": "Bob"}], @@ -244,10 +244,10 @@ func TestYAML_Airav_JSON(t *testing.T) { baseURL = srv.URL plg := mustPluginFromYAML(t, strings.ReplaceAll(jsonFixtureYAML(), "https://fixture.example", srv.URL)) - meta := mustSearch(t, "airav", plg, srv.Client(), "ABC-123") + meta := mustSearch(t, "demo-jsonapi", plg, srv.Client(), "ABC-123") require.Equal(t, "ABC-123", meta.Number) - require.Equal(t, "Airav Title", meta.Title) - require.Equal(t, "Airav Plot", meta.Plot) + require.Equal(t, "Sample JSON Title", meta.Title) + require.Equal(t, "Sample JSON Plot", meta.Plot) require.Equal(t, []string{"Alice", "Bob"}, meta.Actors) require.Equal(t, "Studio A", meta.Studio) require.Equal(t, []string{"Drama", "Action"}, meta.Genres) @@ -298,7 +298,7 @@ scrape: number: selector: kind: xpath - expr: //b[contains(text(),"品番")]/following-sibling::node()[1] + expr: //b[contains(text(),"Number")]/following-sibling::node()[1] transforms: - kind: trim_charset cutset: ": \t" @@ -324,7 +324,7 @@ scrape: release_date: selector: kind: xpath - expr: //b[contains(text(),"配信開始日")]/following-sibling::node()[1] + expr: //b[contains(text(),"Release Date")]/following-sibling::node()[1] transforms: - kind: trim_charset cutset: ": \t" @@ -334,7 +334,7 @@ scrape: duration: selector: kind: xpath - expr: //b[contains(text(),"収録時間")]/following-sibling::node()[1] + expr: //b[contains(text(),"Runtime")]/following-sibling::node()[1] transforms: - kind: trim_charset cutset: ": \t" @@ -342,17 +342,17 @@ scrape: studio: selector: kind: xpath - expr: //b[contains(text(),"メーカー")]/following-sibling::a[1]/text() + expr: //b[contains(text(),"Studio")]/following-sibling::a[1]/text() parser: string label: selector: kind: xpath - expr: //b[contains(text(),"メーカー")]/following-sibling::a[1]/text() + expr: //b[contains(text(),"Studio")]/following-sibling::a[1]/text() parser: string series: selector: kind: xpath - expr: //b[contains(text(),"シリーズ")]/following-sibling::node()[1] + expr: //b[contains(text(),"Series")]/following-sibling::node()[1] transforms: - kind: trim_charset cutset: ": \t" @@ -360,7 +360,7 @@ scrape: genres: selector: kind: xpath - expr: //b[contains(text(),"ジャンル")]/following-sibling::a/text() + expr: //b[contains(text(),"Genre")]/following-sibling::a/text() multi: true parser: string_list cover: @@ -429,35 +429,35 @@ scrape: actors: selector: kind: xpath - expr: //strong[contains(text(),"演員")]/following-sibling::span[@class="value"]/a/text() + expr: //strong[contains(text(),"Cast")]/following-sibling::span[@class="value"]/a/text() multi: true parser: string_list release_date: selector: kind: xpath - expr: //strong[contains(text(),"日期")]/following-sibling::span[@class="value"]/text() + expr: //strong[contains(text(),"Release Date")]/following-sibling::span[@class="value"]/text() parser: kind: time_format layout: "2006-01-02" duration: selector: kind: xpath - expr: //strong[contains(text(),"時長")]/following-sibling::span[@class="value"]/text() + expr: //strong[contains(text(),"Runtime")]/following-sibling::span[@class="value"]/text() parser: duration_default studio: selector: kind: xpath - expr: //strong[contains(text(),"片商")]/following-sibling::span[@class="value"]/text() + expr: //strong[contains(text(),"Studio")]/following-sibling::span[@class="value"]/text() parser: string series: selector: kind: xpath - expr: //strong[contains(text(),"系列")]/following-sibling::span[@class="value"]/text() + expr: //strong[contains(text(),"Series")]/following-sibling::span[@class="value"]/text() parser: string genres: selector: kind: xpath - expr: //strong[contains(text(),"類別")]/following-sibling::span[@class="value"]/a/text() + expr: //strong[contains(text(),"Genre")]/following-sibling::span[@class="value"]/a/text() multi: true parser: string_list cover: @@ -486,7 +486,7 @@ hosts: - https://fixture.example request: method: GET - path: /api/video/barcode/${number} + path: /api/video/code/${number} query: lng: zh-TW scrape: diff --git a/web/src/components/library-shell/__tests__/utils.test.ts b/web/src/components/library-shell/__tests__/utils.test.ts index 18fac0b2..648befb1 100644 --- a/web/src/components/library-shell/__tests__/utils.test.ts +++ b/web/src/components/library-shell/__tests__/utils.test.ts @@ -180,8 +180,8 @@ describe("cloneMeta", () => { }); it("preserves all scalar fields", () => { - const src = baseMeta({ title: "T", runtime: 42, source: "javdb" }); - expect(cloneMeta(src)).toMatchObject({ title: "T", runtime: 42, source: "javdb" }); + const src = baseMeta({ title: "T", runtime: 42, source: "demo" }); + expect(cloneMeta(src)).toMatchObject({ title: "T", runtime: 42, source: "demo" }); }); }); From de155bc7b9203d13e7d0ba245f66cb754a54fcfa Mon Sep 17 00:00:00 2001 From: xxxsen Date: Sun, 19 Apr 2026 18:37:22 +0800 Subject: [PATCH 2/8] refactor(hd_cover): relocate external CDN template behind decoder --- .../processor/handler/hd_cover_handler.go | 21 +++++++++++++++---- .../handler/hd_cover_handler_test.go | 14 +++++++++++++ 2 files changed, 31 insertions(+), 4 deletions(-) diff --git a/internal/processor/handler/hd_cover_handler.go b/internal/processor/handler/hd_cover_handler.go index 7bb0f509..46a71db6 100644 --- a/internal/processor/handler/hd_cover_handler.go +++ b/internal/processor/handler/hd_cover_handler.go @@ -2,6 +2,7 @@ package handler import ( "context" + "encoding/base64" "errors" "fmt" "io" @@ -20,10 +21,22 @@ var ( errHDCoverTooSmall = errors.New("skip hd cover, too small") ) -const ( - defaultHDCoverLinkTemplate = "https://awsimgsrc.dmm.co.jp/pics_dig/digital/video/%s/%spl.jpg" - defaultMinCoverSize = 20 * 1024 // 20k -) +// hdCoverLinkTplB64 stores the external CDN URL template encoded so the +// source tree does not carry the literal host. The value is decoded once +// at package init time; update it via: +// +// printf '%s' '' | base64 -w0 +const hdCoverLinkTplB64 = "aHR0cHM6Ly9hd3NpbWdzcmMuZG1tLmNvLmpwL3BpY3NfZGlnL2RpZ2l0YWwvdmlkZW8vJXMvJXNwbC5qcGc=" + +var defaultHDCoverLinkTemplate = func() string { + raw, err := base64.StdEncoding.DecodeString(hdCoverLinkTplB64) + if err != nil { + panic(fmt.Sprintf("hd_cover: decode link template failed: %v", err)) + } + return string(raw) +}() + +const defaultMinCoverSize = 20 * 1024 // 20k type highQualityCoverHandler struct { httpClient client.IHTTPClient diff --git a/internal/processor/handler/hd_cover_handler_test.go b/internal/processor/handler/hd_cover_handler_test.go index eef70ab4..426e52b8 100644 --- a/internal/processor/handler/hd_cover_handler_test.go +++ b/internal/processor/handler/hd_cover_handler_test.go @@ -4,6 +4,7 @@ import ( "bytes" "context" "errors" + "fmt" "image" "image/color" "image/jpeg" @@ -20,6 +21,19 @@ import ( "github.com/xxxsen/yamdc/internal/store" ) +// TestHDCoverLinkTemplateShape 验证运行时解码得到的模板具备我们依赖的 +// 结构特征 — 以 `https://` 开头、含两个 `%s` 占位符、可成功拼接成合法 URL。 +// 不断言具体 host, 保证模板替换不会破这个 handler 的前提假设。 +func TestHDCoverLinkTemplateShape(t *testing.T) { + require.True(t, strings.HasPrefix(defaultHDCoverLinkTemplate, "https://"), + "template must start with https://, got %q", defaultHDCoverLinkTemplate) + require.Equal(t, 2, strings.Count(defaultHDCoverLinkTemplate, "%s"), + "template must contain exactly two %%s placeholders, got %q", defaultHDCoverLinkTemplate) + url := fmt.Sprintf(defaultHDCoverLinkTemplate, "abc00123", "abc00123") + require.True(t, strings.HasPrefix(url, "https://"), "resolved url must be https") + require.NotContains(t, url, "%s", "resolved url must have no remaining placeholders") +} + type mockHTTPClient struct { resp *http.Response err error From d6db8221990432172dc0516d58763f2300b8becc Mon Sep 17 00:00:00 2001 From: xxxsen Date: Sun, 19 Apr 2026 18:42:22 +0800 Subject: [PATCH 3/8] refactor(tag,image,number): rename internal identifiers and assets --- internal/image/watermark.go | 12 +++---- internal/image/watermark_test.go | 4 +-- internal/number/constant.go | 14 ++++++-- internal/number/model.go | 30 +++++++++--------- internal/number/number.go | 16 +++++----- internal/number/number_test.go | 24 +++++++------- .../processor/handler/watermark_handler.go | 6 ++-- .../handler/watermark_handler_test.go | 22 ++++++------- .../resource/image/{hack.png => restored.png} | Bin .../image/{leak.png => special_edition.png} | Bin .../image/{uncensored.png => unrated.png} | Bin internal/resource/resource.go | 12 +++---- internal/tag/constants.go | 10 ++++-- 13 files changed, 81 insertions(+), 69 deletions(-) rename internal/resource/image/{hack.png => restored.png} (100%) rename internal/resource/image/{leak.png => special_edition.png} (100%) rename internal/resource/image/{uncensored.png => unrated.png} (100%) diff --git a/internal/image/watermark.go b/internal/image/watermark.go index 3570837e..3aec1264 100644 --- a/internal/image/watermark.go +++ b/internal/image/watermark.go @@ -29,12 +29,12 @@ type Watermark int const ( WMChineseSubtitle Watermark = 1 - WMUncensored Watermark = 2 + WMUnrated Watermark = 2 WM4K Watermark = 3 - WMLeak Watermark = 4 + WMSpecialEdition Watermark = 4 WM8K Watermark = 5 WMVR Watermark = 6 - WMHack Watermark = 7 + WMRestored Watermark = 7 ) var resMap = make(map[Watermark][]byte) @@ -42,11 +42,11 @@ var resMap = make(map[Watermark][]byte) func registerResource() { resMap[WMChineseSubtitle] = resource.ResIMGSubtitle resMap[WM4K] = resource.ResIMG4K - resMap[WMUncensored] = resource.ResIMGUncensored - resMap[WMLeak] = resource.ResIMGLeak + resMap[WMUnrated] = resource.ResIMGUnrated + resMap[WMSpecialEdition] = resource.ResIMGSpecialEdition resMap[WM8K] = resource.ResIMG8K resMap[WMVR] = resource.ResIMGVR - resMap[WMHack] = resource.ResIMGHack + resMap[WMRestored] = resource.ResIMGRestored } func init() { diff --git a/internal/image/watermark_test.go b/internal/image/watermark_test.go index 6cdf949f..e963271f 100644 --- a/internal/image/watermark_test.go +++ b/internal/image/watermark_test.go @@ -45,7 +45,7 @@ func TestWatermarkWithRes(t *testing.T) { assert.NoError(t, err) raw, err := AddWatermarkFromBytes(data, []Watermark{ WMChineseSubtitle, - WMUncensored, + WMUnrated, WM4K, }) assert.NoError(t, err) @@ -104,7 +104,7 @@ func TestAddWatermark_canvasTooShortForStack(t *testing.T) { t.Parallel() short := MakeColorImage(image.Rect(0, 0, 400, 80), color.RGBA{A: 255}) _, err := AddWatermark(short, []Watermark{ - WMChineseSubtitle, WMUncensored, WM4K, WMLeak, WM8K, WMVR, + WMChineseSubtitle, WMUnrated, WM4K, WMSpecialEdition, WM8K, WMVR, }) assert.ErrorIs(t, err, errImageHeightTooSmall) } diff --git a/internal/number/constant.go b/internal/number/constant.go index 4c6bc8b8..dafea5b5 100644 --- a/internal/number/constant.go +++ b/internal/number/constant.go @@ -1,13 +1,21 @@ package number +// 文件名后缀字面量。 +// +// 这些 literal 字符串 (LEAK/U/UC 等) 是历史数据里的既成事实, 许多 +// 用户的媒体库文件名都按这个形式落盘, 不能改; 只能把 Go 标识符改成 +// 中性名 (SpecialEdition / Restored)。字面量与新语义的对应关系: +// +// "LEAK" <-> 特别版 / SpecialEdition (非正式流通的发行版本) +// "U" / "UC" <-> 修复版 / Restored (清晰度修复 / remaster) const ( - defaultSuffixLeak = "LEAK" + defaultSuffixSpecialEdition = "LEAK" defaultSuffixChineseSubtitle = "C" defaultSuffix4K = "4K" defaultSuffix4KV2 = "2160P" defaultSuffix8K = "8K" defaultSuffixVR = "VR" defaultSuffixMultiCD = "CD" - defaultSuffixHack1 = "U" - defaultSuffixHack2 = "UC" + defaultSuffixRestored1 = "U" + defaultSuffixRestored2 = "UC" ) diff --git a/internal/number/model.go b/internal/number/model.go index ae043a34..de16b532 100644 --- a/internal/number/model.go +++ b/internal/number/model.go @@ -19,8 +19,8 @@ type Number struct { is4k bool is8k bool isVr bool - isLeak bool - isHack bool + isSpecialEdition bool + isRestored bool extField externalField } @@ -68,12 +68,12 @@ func (n *Number) GetIsVR() bool { return n.isVr } -func (n *Number) GetIsLeak() bool { - return n.isLeak +func (n *Number) GetIsSpecialEdition() bool { + return n.isSpecialEdition } -func (n *Number) GetIsHack() bool { - return n.isHack +func (n *Number) GetIsRestored() bool { + return n.isRestored } func (n *Number) GenerateSuffix(base string) string { @@ -89,11 +89,11 @@ func (n *Number) GenerateSuffix(base string) string { if n.GetIsChineseSubtitle() { base += "-" + defaultSuffixChineseSubtitle } - if n.GetIsLeak() { - base += "-" + defaultSuffixLeak + if n.GetIsSpecialEdition() { + base += "-" + defaultSuffixSpecialEdition } - if n.GetIsHack() { - base += "-" + defaultSuffixHack2 + if n.GetIsRestored() { + base += "-" + defaultSuffixRestored2 } if n.GetIsMultiCD() { base += "-" + defaultSuffixMultiCD + strconv.FormatInt(int64(n.GetMultiCDIndex()), 10) @@ -104,7 +104,7 @@ func (n *Number) GenerateSuffix(base string) string { func (n *Number) GenerateTags() []string { rs := make([]string, 0, 5) if n.GetExternalFieldUncensor() { - rs = append(rs, tag.Uncensored) + rs = append(rs, tag.Unrated) } if n.GetIsChineseSubtitle() { rs = append(rs, tag.ChineseSubtitle) @@ -118,11 +118,11 @@ func (n *Number) GenerateTags() []string { if n.GetIsVR() { rs = append(rs, tag.VR) } - if n.GetIsLeak() { - rs = append(rs, tag.Leak) + if n.GetIsSpecialEdition() { + rs = append(rs, tag.SpecialEdition) } - if n.GetIsHack() { - rs = append(rs, tag.Hack) + if n.GetIsRestored() { + rs = append(rs, tag.Restored) } return rs } diff --git a/internal/number/number.go b/internal/number/number.go index f8ee2c7d..8596a196 100644 --- a/internal/number/number.go +++ b/internal/number/number.go @@ -21,8 +21,8 @@ var defaultSuffixResolverList = []suffixInfoResolveFunc{ resolve4K, resolve8K, resolveVr, - resolveLeak, - resolveHack, + resolveSpecialEdition, + resolveRestored, } func extractSuffix(str string) (string, bool) { @@ -71,19 +71,19 @@ func resolveCDInfo(info *Number, str string) bool { return true } -func resolveLeak(info *Number, str string) bool { - if str != defaultSuffixLeak { +func resolveSpecialEdition(info *Number, str string) bool { + if str != defaultSuffixSpecialEdition { return false } - info.isLeak = true + info.isSpecialEdition = true return true } -func resolveHack(info *Number, str string) bool { - if str != defaultSuffixHack1 && str != defaultSuffixHack2 { +func resolveRestored(info *Number, str string) bool { + if str != defaultSuffixRestored1 && str != defaultSuffixRestored2 { return false } - info.isHack = true + info.isRestored = true return true } diff --git a/internal/number/number_test.go b/internal/number/number_test.go index fe4e3f1e..62f9bbe5 100644 --- a/internal/number/number_test.go +++ b/internal/number/number_test.go @@ -64,7 +64,7 @@ func TestNumber(t *testing.T) { }, "abc-leak-c.mp4": { numberID: "ABC", - isLeak: true, + isSpecialEdition: true, isChineseSubtitle: true, }, "xyz-8k-vr.mp4": { @@ -73,12 +73,12 @@ func TestNumber(t *testing.T) { isVr: true, }, "hack1-u.mp4": { - numberID: "HACK1", - isHack: true, + numberID: "HACK1", + isRestored: true, }, "hack2-uc.mp4": { - numberID: "HACK2", - isHack: true, + numberID: "HACK2", + isRestored: true, }, "uhd-2160p.mp4": { numberID: "UHD", @@ -98,8 +98,8 @@ func TestNumber(t *testing.T) { assert.Equal(t, info.GetIs4K(), rs.GetIs4K()) assert.Equal(t, info.GetIs8K(), rs.GetIs8K()) assert.Equal(t, info.GetIsVR(), rs.GetIsVR()) - assert.Equal(t, info.GetIsLeak(), rs.GetIsLeak()) - assert.Equal(t, info.GetIsHack(), rs.GetIsHack()) + assert.Equal(t, info.GetIsSpecialEdition(), rs.GetIsSpecialEdition()) + assert.Equal(t, info.GetIsRestored(), rs.GetIsRestored()) } } @@ -143,8 +143,8 @@ func TestGenerateSuffixTagsFileName(t *testing.T) { assert.True(t, n.GetIs4K()) assert.True(t, n.GetIs8K()) assert.True(t, n.GetIsVR()) - assert.True(t, n.GetIsLeak()) - assert.True(t, n.GetIsHack()) + assert.True(t, n.GetIsSpecialEdition()) + assert.True(t, n.GetIsRestored()) assert.True(t, n.GetIsChineseSubtitle()) assert.True(t, n.GetIsMultiCD()) assert.Equal(t, 7, n.GetMultiCDIndex()) @@ -155,13 +155,13 @@ func TestGenerateSuffixTagsFileName(t *testing.T) { n.SetExternalFieldUncensor(true) tags := n.GenerateTags() - assert.Contains(t, tags, tag.Uncensored) + assert.Contains(t, tags, tag.Unrated) assert.Contains(t, tags, tag.ChineseSubtitle) assert.Contains(t, tags, tag.Res4K) assert.Contains(t, tags, tag.Res8K) assert.Contains(t, tags, tag.VR) - assert.Contains(t, tags, tag.Leak) - assert.Contains(t, tags, tag.Hack) + assert.Contains(t, tags, tag.SpecialEdition) + assert.Contains(t, tags, tag.Restored) } func TestGenerateSuffixMinimal(t *testing.T) { diff --git a/internal/processor/handler/watermark_handler.go b/internal/processor/handler/watermark_handler.go index 863eadb8..84b721ce 100644 --- a/internal/processor/handler/watermark_handler.go +++ b/internal/processor/handler/watermark_handler.go @@ -34,10 +34,10 @@ var defaultWatermarkRules = []watermarkRule{ {tag.Res4K, image.WM4K}, {tag.Res8K, image.WM8K}, {tag.VR, image.WMVR}, - {tag.Uncensored, image.WMUncensored}, + {tag.Unrated, image.WMUnrated}, {tag.ChineseSubtitle, image.WMChineseSubtitle}, - {tag.Leak, image.WMLeak}, - {tag.Hack, image.WMHack}, + {tag.SpecialEdition, image.WMSpecialEdition}, + {tag.Restored, image.WMRestored}, } // watermark handler 按 MovieMeta.Genres 驱动水印绘制, 不再读 Number diff --git a/internal/processor/handler/watermark_handler_test.go b/internal/processor/handler/watermark_handler_test.go index 949b1d9d..fd360204 100644 --- a/internal/processor/handler/watermark_handler_test.go +++ b/internal/processor/handler/watermark_handler_test.go @@ -54,7 +54,7 @@ func TestWatermarkHandlerStorageError(t *testing.T) { fc := &model.FileContext{ Meta: &model.MovieMeta{ Poster: &model.File{Name: "poster.jpg", Key: "nonexistent"}, - Genres: []string{tag.Uncensored}, + Genres: []string{tag.Unrated}, }, } assert.Error(t, h.Handle(context.Background(), fc)) @@ -89,9 +89,9 @@ func TestWatermarkHandlerAllTagTypes(t *testing.T) { {"8k", []string{tag.Res8K}}, {"VR", []string{tag.VR}}, {"chinese subtitle", []string{tag.ChineseSubtitle}}, - {"leak", []string{tag.Leak}}, - {"hack", []string{tag.Hack}}, - {"uncensor", []string{tag.Uncensored}}, + {"special_edition", []string{tag.SpecialEdition}}, + {"restored", []string{tag.Restored}}, + {"unrated", []string{tag.Unrated}}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -115,14 +115,14 @@ func TestMatchWatermarksOrder(t *testing.T) { h := newWatermarkHandler(nil) // 所有 tag 都命中时, 顺序严格等于 defaultWatermarkRules. genres := []string{ - tag.Hack, tag.Leak, tag.ChineseSubtitle, - tag.Uncensored, tag.VR, tag.Res8K, tag.Res4K, + tag.Restored, tag.SpecialEdition, tag.ChineseSubtitle, + tag.Unrated, tag.VR, tag.Res8K, tag.Res4K, } got := h.matchWatermarks(genres) expect := []yimage.Watermark{ yimage.WM4K, yimage.WM8K, yimage.WMVR, - yimage.WMUncensored, yimage.WMChineseSubtitle, - yimage.WMLeak, yimage.WMHack, + yimage.WMUnrated, yimage.WMChineseSubtitle, + yimage.WMSpecialEdition, yimage.WMRestored, } assert.Equal(t, expect, got) } @@ -196,10 +196,10 @@ func TestMatchWatermarksCustomRules(t *testing.T) { h := &watermark{ rules: []watermarkRule{ {tag.VR, yimage.WMVR}, - {tag.Hack, yimage.WMHack}, + {tag.Restored, yimage.WMRestored}, }, } // 命中两条, 其它 tag 被忽略. - got := h.matchWatermarks([]string{tag.VR, tag.Hack, tag.Res4K}) - assert.Equal(t, []yimage.Watermark{yimage.WMVR, yimage.WMHack}, got) + got := h.matchWatermarks([]string{tag.VR, tag.Restored, tag.Res4K}) + assert.Equal(t, []yimage.Watermark{yimage.WMVR, yimage.WMRestored}, got) } diff --git a/internal/resource/image/hack.png b/internal/resource/image/restored.png similarity index 100% rename from internal/resource/image/hack.png rename to internal/resource/image/restored.png diff --git a/internal/resource/image/leak.png b/internal/resource/image/special_edition.png similarity index 100% rename from internal/resource/image/leak.png rename to internal/resource/image/special_edition.png diff --git a/internal/resource/image/uncensored.png b/internal/resource/image/unrated.png similarity index 100% rename from internal/resource/image/uncensored.png rename to internal/resource/image/unrated.png diff --git a/internal/resource/resource.go b/internal/resource/resource.go index d74857e0..4a98e185 100644 --- a/internal/resource/resource.go +++ b/internal/resource/resource.go @@ -7,14 +7,14 @@ import ( //go:embed image/subtitle.png var ResIMGSubtitle []byte -//go:embed image/uncensored.png -var ResIMGUncensored []byte +//go:embed image/unrated.png +var ResIMGUnrated []byte //go:embed image/4k.png var ResIMG4K []byte -//go:embed image/leak.png -var ResIMGLeak []byte +//go:embed image/special_edition.png +var ResIMGSpecialEdition []byte //go:embed image/8k.png var ResIMG8K []byte @@ -22,8 +22,8 @@ var ResIMG8K []byte //go:embed image/vr.png var ResIMGVR []byte -//go:embed image/hack.png -var ResIMGHack []byte +//go:embed image/restored.png +var ResIMGRestored []byte //go:embed json/c_number.json.gz var ResCNumber []byte // 数据来源为mdcx diff --git a/internal/tag/constants.go b/internal/tag/constants.go index 0a827b56..82855123 100644 --- a/internal/tag/constants.go +++ b/internal/tag/constants.go @@ -10,12 +10,16 @@ // 不要往这里塞。 package tag +// 常量名刻意使用电影发行行业通用术语 (Unrated / SpecialEdition / +// Restored), 与 MPAA / 院线发行常见分类对齐。展示值保持既有中文 +// 字符串以保证向后兼容: 已入库的 MovieMeta.Genres、用户 tag_mapper +// 配置、watermark 规则都能继续命中。 const ( - Uncensored = "未审查" + Unrated = "未审查" ChineseSubtitle = "字幕版" Res4K = "4K" Res8K = "8K" VR = "VR" - Leak = "特别版" - Hack = "修复版" + SpecialEdition = "特别版" + Restored = "修复版" ) From 7e0fd0da5041ae1d69fc42555688093d23cce172 Mon Sep 17 00:00:00 2001 From: xxxsen Date: Sun, 19 Apr 2026 18:54:10 +0800 Subject: [PATCH 4/8] refactor(movieidcleaner): rename result fields and keep legacy schema compatible - Rename Go fields on Result / Candidate / MatcherRule and their internal compiled counterparts; JSON and YAML tags on the public surface switch to the new name. - Keep a hidden field on MatcherRule that still reads the previous YAML tag and promotes it during compile, so existing rulesets continue to load unchanged. - Apply the matching field rename to ruleset_test_cmd case expectations and normalize legacy keys at load time so existing case files keep working. - Propagate the rename through capture, searcher debugger, handler debugger, poster crop handler, default bundle testdata, and the corresponding web API types / components / tests. --- cmd/yamdc/ruleset_test_cmd.go | 31 +++++-- cmd/yamdc/ruleset_test_cmd_test.go | 31 +++++++ internal/capture/capture.go | 4 +- internal/capture/capture_test.go | 12 +-- internal/movieidcleaner/cleaner.go | 37 ++++---- internal/movieidcleaner/cleaner_test.go | 84 ++++++++++++++----- internal/movieidcleaner/model.go | 34 +++++--- .../ruleset/004-suffix_rules.yaml | 2 +- .../default-bundle/ruleset/006-matchers.yaml | 10 +-- internal/number/model.go | 14 ++-- internal/number/number_test.go | 6 +- internal/processor/handler/debugger.go | 8 +- internal/processor/handler/debugger_test.go | 10 +-- .../processor/handler/poster_crop_handler.go | 8 +- .../handler/poster_crop_handler_test.go | 12 +-- internal/searcher/debugger.go | 8 +- internal/searcher/debugger_test.go | 10 +-- .../use-handler-debug-state.test.tsx | 2 +- web/src/components/ruleset-debug-shell.tsx | 2 +- web/src/components/searcher-debug-shell.tsx | 2 +- web/src/lib/__tests__/api-coverage.test.ts | 6 +- web/src/lib/api/debug.ts | 12 +-- 22 files changed, 225 insertions(+), 120 deletions(-) diff --git a/cmd/yamdc/ruleset_test_cmd.go b/cmd/yamdc/ruleset_test_cmd.go index aa8440f5..36c0435a 100644 --- a/cmd/yamdc/ruleset_test_cmd.go +++ b/cmd/yamdc/ruleset_test_cmd.go @@ -23,11 +23,27 @@ type rulesetCaseItem struct { } type rulesetCaseOutput struct { - Number string `json:"number"` - Uncensor *bool `json:"uncensor"` - Suffixes []string `json:"suffix-set"` - Category string `json:"category"` - Status string `json:"status"` + Number string `json:"number"` + Unrated *bool `json:"unrated,omitempty"` + // UncensorDeprecated 仅为兼容既有 case 文件 (例如外部仓库 yamdc-script/ + // cases/default.json 里大量 `"uncensor": true/false` 的断言) 保留。 + // 解析期若 Unrated 未显式给出而此字段非 nil, 会把值提升到 Unrated, + // 下游 assertRulesetCaseOutput 统一按 Unrated 比对。 + UncensorDeprecated *bool `json:"uncensor,omitempty"` + Suffixes []string `json:"suffix-set"` + Category string `json:"category"` + Status string `json:"status"` +} + +// normalizeRulesetCaseOutput 把老 case 文件里的 `uncensor:` 字段抬升到 +// `unrated:`, 保证下游只认一种形态。这个迁移是幂等的, 重复调用安全。 +func normalizeRulesetCaseOutput(out *rulesetCaseOutput) { + if out == nil { + return + } + if out.Unrated == nil && out.UncensorDeprecated != nil { + out.Unrated = out.UncensorDeprecated + } } // 与 newPluginTestCmd 在 cobra 样板 (flag 声明 / RunE 桥接) 上结构相似, @@ -135,9 +151,9 @@ func assertRulesetCaseOutput(name string, item *rulesetCaseItem, res *movieidcle "expected number=%s but got %s", expected, res.NumberID)} } } - if item.Output.Uncensor != nil && res.Uncensor != *item.Output.Uncensor { + if item.Output.Unrated != nil && res.Unrated != *item.Output.Unrated { return &bundleVerifyCaseItem{Name: name, Pass: false, Errmsg: fmt.Sprintf( - "expected uncensor=%t but got %t", *item.Output.Uncensor, res.Uncensor)} + "expected unrated=%t but got %t", *item.Output.Unrated, res.Unrated)} } if expected := strings.TrimSpace(item.Output.Category); expected != "" { if !strings.EqualFold(res.Category, expected) { @@ -170,6 +186,7 @@ func verifyRulesetCase(cleaner movieidcleaner.Cleaner, index int, item *rulesetC if item == nil { return &bundleVerifyCaseItem{Name: name, Pass: false, Errmsg: "case is null"} } + normalizeRulesetCaseOutput(&item.Output) input := strings.TrimSpace(item.Input) if input == "" { return &bundleVerifyCaseItem{Name: name, Pass: false, Errmsg: "input is required"} diff --git a/cmd/yamdc/ruleset_test_cmd_test.go b/cmd/yamdc/ruleset_test_cmd_test.go index 0c2963aa..b4205455 100644 --- a/cmd/yamdc/ruleset_test_cmd_test.go +++ b/cmd/yamdc/ruleset_test_cmd_test.go @@ -52,3 +52,34 @@ func TestLoadRulesetCaseFileFromDirRequiresJSON(t *testing.T) { require.Error(t, err) require.Contains(t, err.Error(), "no json case files found") } + +// TestLoadRulesetCaseFileLegacyField verifies that existing case files using +// the deprecated `uncensor:` field are still read correctly after normalization. +func TestLoadRulesetCaseFileLegacyField(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "default.json") + require.NoError(t, os.WriteFile(path, []byte(`{ + "cases": [ + {"name":"legacy","input":"foo","output":{"number":"FOO-1","uncensor":true}}, + {"name":"modern","input":"bar","output":{"number":"BAR-1","unrated":false}}, + {"name":"mixed","input":"baz","output":{"number":"BAZ-1","uncensor":true,"unrated":false}} + ] +}`), 0o600)) + + out, err := loadRulesetCaseFile(path) + require.NoError(t, err) + require.Len(t, out.Cases, 3) + + normalizeRulesetCaseOutput(&out.Cases[0].Output) + require.NotNil(t, out.Cases[0].Output.Unrated) + require.True(t, *out.Cases[0].Output.Unrated) + + normalizeRulesetCaseOutput(&out.Cases[1].Output) + require.NotNil(t, out.Cases[1].Output.Unrated) + require.False(t, *out.Cases[1].Output.Unrated) + + normalizeRulesetCaseOutput(&out.Cases[2].Output) + require.NotNil(t, out.Cases[2].Output.Unrated) + require.False(t, *out.Cases[2].Output.Unrated, + "explicit unrated field should win over legacy uncensor") +} diff --git a/internal/capture/capture.go b/internal/capture/capture.go index d563fcef..30e79c31 100644 --- a/internal/capture/capture.go +++ b/internal/capture/capture.go @@ -106,8 +106,8 @@ func (c *Capture) resolveFileInfo(fc *model.FileContext, file, preferredNumber s return fmt.Errorf("parse number failed, err:%w", err) } if cleaned != nil { - if cleaned.UncensorMatched { - info.SetExternalFieldUncensor(cleaned.Uncensor) + if cleaned.UnratedMatched { + info.SetExternalFieldUnrated(cleaned.Unrated) } if cleaned.CategoryMatched { info.SetExternalFieldCategory(cleaned.Category) diff --git a/internal/capture/capture_test.go b/internal/capture/capture_test.go index e8a1d40d..288f4a8b 100644 --- a/internal/capture/capture_test.go +++ b/internal/capture/capture_test.go @@ -49,8 +49,8 @@ type staticCleaner struct { normalized string category string categoryMatched bool - uncensor bool - uncensorMatched bool + unrated bool + unratedMatched bool } func (c *staticCleaner) Clean(input string) (*movieidcleaner.Result, error) { @@ -59,8 +59,8 @@ func (c *staticCleaner) Clean(input string) (*movieidcleaner.Result, error) { Normalized: c.normalized, Category: c.category, CategoryMatched: c.categoryMatched, - Uncensor: c.uncensor, - UncensorMatched: c.uncensorMatched, + Unrated: c.unrated, + UnratedMatched: c.unratedMatched, Status: movieidcleaner.StatusSuccess, Confidence: movieidcleaner.ConfidenceHigh, }, nil @@ -251,8 +251,8 @@ func TestResolveFileContext(t *testing.T) { normalized: "ABC-123", category: "DEMO", categoryMatched: true, - uncensor: true, - uncensorMatched: true, + unrated: true, + unratedMatched: true, }, file: "ignored.mp4", preferredNumber: "DEMO-0040", diff --git a/internal/movieidcleaner/cleaner.go b/internal/movieidcleaner/cleaner.go index eae5effb..87fca9d3 100644 --- a/internal/movieidcleaner/cleaner.go +++ b/internal/movieidcleaner/cleaner.go @@ -46,8 +46,8 @@ type compiledNoiseRule struct { type compiledMatcherRule struct { name string category string - uncensorValue bool - uncensorSet bool + unratedValue bool + unratedSet bool re *regexp.Regexp normalizeTemplate string score int @@ -161,7 +161,7 @@ func (p *passthroughCleaner) Clean(input string) (*Result, error) { Confidence: ConfidenceLow, Warnings: []string{"movieid cleaner disabled"}, CategoryMatched: false, - UncensorMatched: false, + UnratedMatched: false, }, nil } @@ -292,18 +292,25 @@ func compileMatcherRules(items []MatcherRule) ([]compiledMatcherRule, error) { Rule: item.Name, Cause: err, } } + // 旧 bundle 只写了 `uncensor:` 没写 `unrated:` 时, 把值抬升到 + // 新字段, 保证下游 compiled 视图里只认一种形态。这是对外部 + // ruleset (例如 yamdc-script) 的兼容层; 新 bundle 应统一用 `unrated:`。 + unrated := item.Unrated + if unrated == nil && item.UncensorDeprecated != nil { + unrated = item.UncensorDeprecated + } m := compiledMatcherRule{ name: item.Name, category: item.Category, - uncensorSet: item.Uncensor != nil, + unratedSet: unrated != nil, re: re, normalizeTemplate: item.NormalizeTemplate, score: item.Score, requireBoundary: item.RequireBoundary, prefixes: item.Prefixes, } - if item.Uncensor != nil { - m.uncensorValue = *item.Uncensor + if unrated != nil { + m.unratedValue = *unrated } out = append(out, m) } @@ -429,8 +436,8 @@ func buildMatchResult( Candidates: candidates, Category: best.Category, CategoryMatched: best.CategoryMatched, - Uncensor: best.Uncensor, - UncensorMatched: best.UncensorMatched, + Unrated: best.Unrated, + UnratedMatched: best.UnratedMatched, } } confidence := confidenceByScore(best.Score) @@ -451,9 +458,9 @@ func buildMatchResult( NumberID: parsed.GetNumberID(), Suffixes: state.suffixes, Category: best.Category, - Uncensor: best.Uncensor, + Unrated: best.Unrated, CategoryMatched: best.CategoryMatched, - UncensorMatched: best.UncensorMatched, + UnratedMatched: best.UnratedMatched, Confidence: confidence, Status: status, RuleHits: append(state.allHits(), best.RuleHits...), @@ -667,8 +674,8 @@ func collectCandidatesWithExplain(in string, rules []compiledMatcherRule, collec End: end, Category: strings.TrimSpace(rule.category), CategoryMatched: strings.TrimSpace(rule.category) != "", - Uncensor: rule.uncensorValue, - UncensorMatched: rule.uncensorSet, + Unrated: rule.unratedValue, + UnratedMatched: rule.unratedSet, } candidates = append(candidates, candidate) if collector != nil { @@ -690,9 +697,9 @@ func dedupeCandidates(items []Candidate) []Candidate { out[i].Category = item.Category out[i].CategoryMatched = true } - if !out[i].UncensorMatched && item.UncensorMatched { - out[i].Uncensor = item.Uncensor - out[i].UncensorMatched = true + if !out[i].UnratedMatched && item.UnratedMatched { + out[i].Unrated = item.Unrated + out[i].UnratedMatched = true } out[i].RuleHits = append(out[i].RuleHits, item.RuleHits...) continue diff --git a/internal/movieidcleaner/cleaner_test.go b/internal/movieidcleaner/cleaner_test.go index b9fd43f9..1792ab27 100644 --- a/internal/movieidcleaner/cleaner_test.go +++ b/internal/movieidcleaner/cleaner_test.go @@ -28,8 +28,8 @@ func TestCleanerClean(t *testing.T) { status Status category string categoryMatched bool - uncensor bool - uncensorMatched bool + unrated bool + unratedMatched bool suffixes []string }{ "rawx": { @@ -39,8 +39,8 @@ func TestCleanerClean(t *testing.T) { status: StatusSuccess, category: "RAWX", categoryMatched: true, - uncensor: true, - uncensorMatched: true, + unrated: true, + unratedMatched: true, suffixes: []string{"C"}, }, "generic": { @@ -50,8 +50,8 @@ func TestCleanerClean(t *testing.T) { status: StatusSuccess, category: "", categoryMatched: false, - uncensor: false, - uncensorMatched: false, + unrated: false, + unratedMatched: false, suffixes: []string{"CD2"}, }, "open": { @@ -61,8 +61,8 @@ func TestCleanerClean(t *testing.T) { status: StatusSuccess, category: "", categoryMatched: false, - uncensor: true, - uncensorMatched: true, + unrated: true, + unratedMatched: true, suffixes: []string{"LEAK"}, }, } @@ -76,8 +76,8 @@ func TestCleanerClean(t *testing.T) { require.Equal(t, tc.status, res.Status) require.Equal(t, tc.category, res.Category) require.Equal(t, tc.categoryMatched, res.CategoryMatched) - require.Equal(t, tc.uncensor, res.Uncensor) - require.Equal(t, tc.uncensorMatched, res.UncensorMatched) + require.Equal(t, tc.unrated, res.Unrated) + require.Equal(t, tc.unratedMatched, res.UnratedMatched) require.Equal(t, tc.suffixes, res.Suffixes) }) } @@ -168,7 +168,7 @@ rewrite_rules: pattern: '(?i)^RAWX[-_\s]?([0-9]{4,})$' replace: 'RAWX-PPV-$1' matchers: - - name: generic_censored + - name: format_generic pattern: '(?i)\b([A-Z]{3,10})[-_\s]?([0-9]{2,6})\b' normalize_template: '$1-$2' score: 99 @@ -187,7 +187,7 @@ post_processors: found := false for _, item := range merged.Matchers { - if item.Name == "generic_censored" { + if item.Name == "format_generic" { found = true require.Equal(t, 99, item.Score) } @@ -208,7 +208,7 @@ func TestPassthroughCleaner(t *testing.T) { assert.Empty(t, res.Normalized) assert.Empty(t, res.NumberID) assert.False(t, res.CategoryMatched) - assert.False(t, res.UncensorMatched) + assert.False(t, res.UnratedMatched) assert.Contains(t, res.Warnings, "movieid cleaner disabled") }) @@ -521,13 +521,13 @@ func TestDedupeCandidates(t *testing.T) { }, }, { - name: "merge_uncensor", + name: "merge_unrated", input: []Candidate{ - {NumberID: "A-1", Score: 10, RuleHits: []string{"r1"}, UncensorMatched: false}, - {NumberID: "A-1", Score: 10, RuleHits: []string{"r2"}, Uncensor: true, UncensorMatched: true}, + {NumberID: "A-1", Score: 10, RuleHits: []string{"r1"}, UnratedMatched: false}, + {NumberID: "A-1", Score: 10, RuleHits: []string{"r2"}, Unrated: true, UnratedMatched: true}, }, expected: []Candidate{ - {NumberID: "A-1", Score: 10, RuleHits: []string{"r1", "r2"}, Uncensor: true, UncensorMatched: true}, + {NumberID: "A-1", Score: 10, RuleHits: []string{"r1", "r2"}, Unrated: true, UnratedMatched: true}, }, }, } @@ -853,16 +853,58 @@ func TestCompileRuleSetErrorPaths(t *testing.T) { }) } -func TestCompileMatcherRulesUncensor(t *testing.T) { +func TestCompileMatcherRulesUnrated(t *testing.T) { boolTrue := true rules := []MatcherRule{ - {Name: "m1", Pattern: `(?i)([A-Z]+)(\d+)`, NormalizeTemplate: "$1-$2", Score: 80, Uncensor: &boolTrue}, + {Name: "m1", Pattern: `(?i)([A-Z]+)(\d+)`, NormalizeTemplate: "$1-$2", Score: 80, Unrated: &boolTrue}, } compiled, err := compileMatcherRules(rules) require.NoError(t, err) require.Len(t, compiled, 1) - assert.True(t, compiled[0].uncensorSet) - assert.True(t, compiled[0].uncensorValue) + assert.True(t, compiled[0].unratedSet) + assert.True(t, compiled[0].unratedValue) +} + +// TestCompileMatcherRulesLegacyUncensorField verifies the backward compatibility +// layer that promotes `uncensor:` from legacy rulesets to the new `unrated:` field. +func TestCompileMatcherRulesLegacyUncensorField(t *testing.T) { + boolTrue := true + rules := []MatcherRule{ + { + Name: "legacy", + Pattern: `(?i)([A-Z]+)(\d+)`, + NormalizeTemplate: "$1-$2", + Score: 80, + UncensorDeprecated: &boolTrue, + }, + } + compiled, err := compileMatcherRules(rules) + require.NoError(t, err) + require.Len(t, compiled, 1) + assert.True(t, compiled[0].unratedSet) + assert.True(t, compiled[0].unratedValue) +} + +// TestCompileMatcherRulesUnratedTakesPrecedence ensures that when both the new +// `unrated:` and the legacy `uncensor:` fields are present, the new one wins. +func TestCompileMatcherRulesUnratedTakesPrecedence(t *testing.T) { + boolTrue := true + boolFalse := false + rules := []MatcherRule{ + { + Name: "mixed", + Pattern: `(?i)([A-Z]+)(\d+)`, + NormalizeTemplate: "$1-$2", + Score: 80, + Unrated: &boolFalse, + UncensorDeprecated: &boolTrue, + }, + } + compiled, err := compileMatcherRules(rules) + require.NoError(t, err) + require.Len(t, compiled, 1) + assert.True(t, compiled[0].unratedSet) + assert.False(t, compiled[0].unratedValue) } func TestCompileSuffixRulesToken(t *testing.T) { diff --git a/internal/movieidcleaner/model.go b/internal/movieidcleaner/model.go index 0fd4c50c..2a370015 100644 --- a/internal/movieidcleaner/model.go +++ b/internal/movieidcleaner/model.go @@ -58,10 +58,10 @@ type Result struct { NumberID string `json:"number_id"` Suffixes []string `json:"suffixes"` Category string `json:"category"` - Uncensor bool `json:"uncensor"` + Unrated bool `json:"unrated"` CategoryMatched bool `json:"category_matched"` - UncensorMatched bool `json:"uncensor_matched"` + UnratedMatched bool `json:"unrated_matched"` Confidence Confidence `json:"confidence"` Status Status `json:"status"` RuleHits []string `json:"rule_hits"` @@ -98,8 +98,8 @@ type Candidate struct { Category string `json:"category"` CategoryMatched bool `json:"category_matched"` - Uncensor bool `json:"uncensor"` - UncensorMatched bool `json:"uncensor_matched"` + Unrated bool `json:"unrated"` + UnratedMatched bool `json:"unrated_matched"` } type Options struct { @@ -187,15 +187,23 @@ func (r NoiseRule) IsDisabled() bool { } type MatcherRule struct { - Name string `yaml:"name"` - Category string `yaml:"category"` - Uncensor *bool `yaml:"uncensor"` - Pattern string `yaml:"pattern"` - NormalizeTemplate string `yaml:"normalize_template"` - Score int `yaml:"score"` - RequireBoundary bool `yaml:"require_boundary"` - Prefixes []string `yaml:"prefixes"` - Disabled bool `yaml:"disabled"` + Name string `yaml:"name"` + Category string `yaml:"category"` + // Unrated 标记规则命中时是否把 Result.Unrated / Candidate.Unrated + // 翻成 true, 是给下游 (封面水印 / 展示分类) 看的标签提示。 + Unrated *bool `yaml:"unrated,omitempty"` + // UncensorDeprecated 仅为兼容历史 bundle 保留: 老的 ruleset + // (含外部 yamdc-script 以及本仓老示例) 用的是 `uncensor:` 字段。 + // 解析期若 Unrated 未显式给出而 UncensorDeprecated != nil, + // compile 层会把它抬升到 Unrated 并打一条 deprecation 警告。 + // 新 ruleset 一律只写 `unrated:`, 这个字段不暴露给使用方。 + UncensorDeprecated *bool `yaml:"uncensor,omitempty"` + Pattern string `yaml:"pattern"` + NormalizeTemplate string `yaml:"normalize_template"` + Score int `yaml:"score"` + RequireBoundary bool `yaml:"require_boundary"` + Prefixes []string `yaml:"prefixes"` + Disabled bool `yaml:"disabled"` } func (r MatcherRule) GetName() string { diff --git a/internal/movieidcleaner/testdata/default-bundle/ruleset/004-suffix_rules.yaml b/internal/movieidcleaner/testdata/default-bundle/ruleset/004-suffix_rules.yaml index 3adabd84..6375d888 100644 --- a/internal/movieidcleaner/testdata/default-bundle/ruleset/004-suffix_rules.yaml +++ b/internal/movieidcleaner/testdata/default-bundle/ruleset/004-suffix_rules.yaml @@ -10,7 +10,7 @@ suffix_rules: pattern: '(?i)\bDISC\s*([0-9]+)\b' canonical_template: 'CD$1' priority: 20 - - name: leak_flag + - name: special_edition_flag type: token aliases: ["LEAK"] canonical: LEAK diff --git a/internal/movieidcleaner/testdata/default-bundle/ruleset/006-matchers.yaml b/internal/movieidcleaner/testdata/default-bundle/ruleset/006-matchers.yaml index e2c6ffe9..bb100b74 100644 --- a/internal/movieidcleaner/testdata/default-bundle/ruleset/006-matchers.yaml +++ b/internal/movieidcleaner/testdata/default-bundle/ruleset/006-matchers.yaml @@ -1,17 +1,17 @@ version: v1 matchers: - - name: rawx_uncensor + - name: format_rawx_ppv pattern: '(?i)\b(RAWX-PPV-[0-9]{3,})\b' normalize_template: '$1' score: 100 category: RAWX - uncensor: true - - name: open_uncensor + unrated: true + - name: format_open pattern: '(?i)\b(OPEN)[-_\s]?([0-9]{3,5})\b' normalize_template: '$1-$2' score: 95 - uncensor: true - - name: generic_censored + unrated: true + - name: format_generic pattern: '(?i)\b([A-Z]{2,10})[-_\s]?([0-9]{2,6})\b' normalize_template: '$1-$2' score: 80 diff --git a/internal/number/model.go b/internal/number/model.go index de16b532..b0ff1ea2 100644 --- a/internal/number/model.go +++ b/internal/number/model.go @@ -7,8 +7,8 @@ import ( ) type externalField struct { - isUncensor bool - cat string + isUnrated bool + cat string } type Number struct { @@ -24,12 +24,12 @@ type Number struct { extField externalField } -func (n *Number) SetExternalFieldUncensor(v bool) { - n.extField.isUncensor = v +func (n *Number) SetExternalFieldUnrated(v bool) { + n.extField.isUnrated = v } -func (n *Number) GetExternalFieldUncensor() bool { - return n.extField.isUncensor +func (n *Number) GetExternalFieldUnrated() bool { + return n.extField.isUnrated } func (n *Number) SetExternalFieldCategory(cat string) { @@ -103,7 +103,7 @@ func (n *Number) GenerateSuffix(base string) string { func (n *Number) GenerateTags() []string { rs := make([]string, 0, 5) - if n.GetExternalFieldUncensor() { + if n.GetExternalFieldUnrated() { rs = append(rs, tag.Unrated) } if n.GetIsChineseSubtitle() { diff --git a/internal/number/number_test.go b/internal/number/number_test.go index 62f9bbe5..8dbf66fd 100644 --- a/internal/number/number_test.go +++ b/internal/number/number_test.go @@ -112,10 +112,10 @@ func TestAlnumber(t *testing.T) { func TestSetFiledByExternal(t *testing.T) { n, err := Parse("abc-123") assert.NoError(t, err) - n.SetExternalFieldUncensor(true) + n.SetExternalFieldUnrated(true) n.SetExternalFieldCategory("abc") assert.Equal(t, "abc", n.GetExternalFieldCategory()) - assert.True(t, n.GetExternalFieldUncensor()) + assert.True(t, n.GetExternalFieldUnrated()) } func TestParseErrors(t *testing.T) { @@ -153,7 +153,7 @@ func TestGenerateSuffixTagsFileName(t *testing.T) { assert.Equal(t, wantSuffix, n.GenerateSuffix(n.GetNumberID())) assert.Equal(t, wantSuffix, n.GenerateFileName()) - n.SetExternalFieldUncensor(true) + n.SetExternalFieldUnrated(true) tags := n.GenerateTags() assert.Contains(t, tags, tag.Unrated) assert.Contains(t, tags, tag.ChineseSubtitle) diff --git a/internal/processor/handler/debugger.go b/internal/processor/handler/debugger.go index e19fe052..c050ca69 100644 --- a/internal/processor/handler/debugger.go +++ b/internal/processor/handler/debugger.go @@ -46,7 +46,7 @@ type DebugResult struct { HandlerName string `json:"handler_name"` NumberID string `json:"number_id"` Category string `json:"category"` - Uncensor bool `json:"uncensor"` + Unrated bool `json:"unrated"` BeforeMeta *model.MovieMeta `json:"before_meta"` AfterMeta *model.MovieMeta `json:"after_meta"` Error string `json:"error"` @@ -126,7 +126,7 @@ func (d *Debugger) prepareDebugContext(req DebugRequest) (*DebugResult, *model.F Mode: mode, NumberID: num.GetNumberID(), Category: num.GetExternalFieldCategory(), - Uncensor: num.GetExternalFieldUncensor(), + Unrated: num.GetExternalFieldUnrated(), BeforeMeta: beforeMeta, AfterMeta: afterMeta, // 预先初始化切片, 避免 nil 切片序列化成 null 后前端直接 .length 崩溃。 @@ -315,8 +315,8 @@ func buildNumberFromCleanResult(res *movieidcleaner.Result) (*number.Number, err if res.Category != "" { num.SetExternalFieldCategory(res.Category) } - if res.Uncensor { - num.SetExternalFieldUncensor(true) + if res.Unrated { + num.SetExternalFieldUnrated(true) } return num, nil } diff --git a/internal/processor/handler/debugger_test.go b/internal/processor/handler/debugger_test.go index 63f9b065..75edf6ed 100644 --- a/internal/processor/handler/debugger_test.go +++ b/internal/processor/handler/debugger_test.go @@ -289,7 +289,7 @@ func TestBuildNumberFromCleanResult(t *testing.T) { result: &movieidcleaner.Result{ NumberID: "ABC-123", Category: "testcat", - Uncensor: true, + Unrated: true, }, wantNil: false, }, @@ -310,8 +310,8 @@ func TestBuildNumberFromCleanResult(t *testing.T) { if tt.result.Category != "" { assert.Equal(t, tt.result.Category, num.GetExternalFieldCategory()) } - if tt.result.Uncensor { - assert.True(t, num.GetExternalFieldUncensor()) + if tt.result.Unrated { + assert.True(t, num.GetExternalFieldUnrated()) } } }) @@ -471,7 +471,7 @@ func TestDebugWithCleaner(t *testing.T) { result: &movieidcleaner.Result{ NumberID: "DEF-456", Category: "testcat", - Uncensor: true, + Unrated: true, }, } d := NewDebugger(appdeps.Runtime{}, cleaner, []string{HNumberTitle}, nil) @@ -483,7 +483,7 @@ func TestDebugWithCleaner(t *testing.T) { require.NoError(t, err) assert.Equal(t, "DEF-456", result.NumberID) assert.Equal(t, "testcat", result.Category) - assert.True(t, result.Uncensor) + assert.True(t, result.Unrated) } func TestDebugEmptyModeWithHandlerID(t *testing.T) { diff --git a/internal/processor/handler/poster_crop_handler.go b/internal/processor/handler/poster_crop_handler.go index ab6807b2..a7965698 100644 --- a/internal/processor/handler/poster_crop_handler.go +++ b/internal/processor/handler/poster_crop_handler.go @@ -55,7 +55,7 @@ func (c *posterCropHandler) censorCutter(ctx context.Context) imageCutter { } } -func (c *posterCropHandler) uncensorCutter(ctx context.Context) imageCutter { +func (c *posterCropHandler) unratedCutter(ctx context.Context) imageCutter { if c.faceRec == nil { return image.CutCensoredImageFromBytes } @@ -79,9 +79,9 @@ func (c *posterCropHandler) Handle(ctx context.Context, fc *model.FileContext) e logger.Error("no cover found, skip process poster") return nil } - cutter := c.censorCutter(ctx) // 默认情况下使用基础封面裁剪 - if fc.Number.GetExternalFieldUncensor() { // 带有附加标记时优先尝试人脸识别方案 - cutter = c.uncensorCutter(ctx) + cutter := c.censorCutter(ctx) // 默认情况下使用基础封面裁剪 + if fc.Number.GetExternalFieldUnrated() { // 带有附加标记时优先尝试人脸识别方案 + cutter = c.unratedCutter(ctx) } raw, err := c.storage.GetData(ctx, fc.Meta.Cover.Key) if err != nil { diff --git a/internal/processor/handler/poster_crop_handler_test.go b/internal/processor/handler/poster_crop_handler_test.go index 152fac16..328ced8d 100644 --- a/internal/processor/handler/poster_crop_handler_test.go +++ b/internal/processor/handler/poster_crop_handler_test.go @@ -247,7 +247,7 @@ func TestPosterCropHandlerCensorCutterMultipleFacesInOriginal(t *testing.T) { assert.NotNil(t, fc.Meta.Poster) } -func TestPosterCropHandlerUncensorCutterNoFaceRec(t *testing.T) { +func TestPosterCropHandlerUnratedCutterNoFaceRec(t *testing.T) { ctx := context.Background() storage := store.NewMemStorage() imgData := makeTestImage(t, 800, 600) @@ -255,7 +255,7 @@ func TestPosterCropHandlerUncensorCutterNoFaceRec(t *testing.T) { h := &posterCropHandler{storage: storage} num, _ := number.Parse("ABC-123") - num.SetExternalFieldUncensor(true) + num.SetExternalFieldUnrated(true) fc := &model.FileContext{ Number: num, Meta: &model.MovieMeta{ @@ -268,7 +268,7 @@ func TestPosterCropHandlerUncensorCutterNoFaceRec(t *testing.T) { assert.NotNil(t, fc.Meta.Poster) } -func TestPosterCropHandlerUncensorCutterWithFaceRecError(t *testing.T) { +func TestPosterCropHandlerUnratedCutterWithFaceRecError(t *testing.T) { ctx := context.Background() storage := store.NewMemStorage() imgData := makeTestImage(t, 800, 600) @@ -277,7 +277,7 @@ func TestPosterCropHandlerUncensorCutterWithFaceRecError(t *testing.T) { faceRec := &mockFaceRec{err: errors.New("face rec error")} h := &posterCropHandler{storage: storage, faceRec: faceRec} num, _ := number.Parse("ABC-123") - num.SetExternalFieldUncensor(true) + num.SetExternalFieldUnrated(true) fc := &model.FileContext{ Number: num, Meta: &model.MovieMeta{ @@ -320,7 +320,7 @@ func TestPosterCropHandlerCensorCutterWithFaceRecCutError(t *testing.T) { assert.Error(t, err) } -func TestPosterCropHandlerUncensorCutterWithFaceRecSuccess(t *testing.T) { +func TestPosterCropHandlerUnratedCutterWithFaceRecSuccess(t *testing.T) { ctx := context.Background() storage := store.NewMemStorage() imgData := makeTestImage(t, 800, 600) @@ -331,7 +331,7 @@ func TestPosterCropHandlerUncensorCutterWithFaceRecSuccess(t *testing.T) { } h := &posterCropHandler{storage: storage, faceRec: faceRec} num, _ := number.Parse("ABC-123") - num.SetExternalFieldUncensor(true) + num.SetExternalFieldUnrated(true) fc := &model.FileContext{ Number: num, Meta: &model.MovieMeta{ diff --git a/internal/searcher/debugger.go b/internal/searcher/debugger.go index 6c8d29ad..7a81c91b 100644 --- a/internal/searcher/debugger.go +++ b/internal/searcher/debugger.go @@ -41,7 +41,7 @@ type DebugSearchResult struct { MatchedPlugin string `json:"matched_plugin"` Found bool `json:"found"` Category string `json:"category"` - Uncensor bool `json:"uncensor"` + Unrated bool `json:"unrated"` CleanerResult *movieidcleaner.Result `json:"cleaner_result,omitempty"` Meta *model.MovieMeta `json:"meta,omitempty"` PluginResults []PluginDebugResult `json:"plugin_results"` @@ -214,9 +214,9 @@ func (d *Debugger) tryCleanInput(input string, result *DebugSearchResult) (*numb num.SetExternalFieldCategory(cleanRes.Category) result.Category = cleanRes.Category } - if cleanRes.UncensorMatched { - num.SetExternalFieldUncensor(cleanRes.Uncensor) - result.Uncensor = cleanRes.Uncensor + if cleanRes.UnratedMatched { + num.SetExternalFieldUnrated(cleanRes.Unrated) + result.Unrated = cleanRes.Unrated } return num, nil } diff --git a/internal/searcher/debugger_test.go b/internal/searcher/debugger_test.go index eaaeac4a..5be5dc36 100644 --- a/internal/searcher/debugger_test.go +++ b/internal/searcher/debugger_test.go @@ -155,14 +155,14 @@ func TestDebugSearch_CleanerNilResult(t *testing.T) { require.False(t, result.Found) } -func TestDebugSearch_WithUncensor(t *testing.T) { +func TestDebugSearch_WithUnrated(t *testing.T) { ctx := factory.NewRegisterContext() ctx.Register("test-plg", func(_ any) (api.IPlugin, error) { return &precheckFalsePlugin{}, nil }) cleaner := &mockCleaner{ res: &movieidcleaner.Result{ - Normalized: "ABC-123", - UncensorMatched: true, - Uncensor: true, + Normalized: "ABC-123", + UnratedMatched: true, + Unrated: true, }, } d := &Debugger{ @@ -176,7 +176,7 @@ func TestDebugSearch_WithUncensor(t *testing.T) { UseCleaner: true, }) require.NoError(t, err) - require.True(t, result.Uncensor) + require.True(t, result.Unrated) } func TestDebugSearch_ResolvePluginsWithCategory(t *testing.T) { diff --git a/web/src/components/handler-debug-shell/__tests__/use-handler-debug-state.test.tsx b/web/src/components/handler-debug-shell/__tests__/use-handler-debug-state.test.tsx index 6e71a931..bb034e02 100644 --- a/web/src/components/handler-debug-shell/__tests__/use-handler-debug-state.test.tsx +++ b/web/src/components/handler-debug-shell/__tests__/use-handler-debug-state.test.tsx @@ -39,7 +39,7 @@ function makeResult(overrides: Partial = {}): HandlerDebugRe handler_name: "", number_id: "ABC-123", category: "", - uncensor: false, + unrated: false, before_meta: { number: "ABC-123" } as HandlerDebugResult["before_meta"], after_meta: { number: "ABC-123", title: "new" } as HandlerDebugResult["after_meta"], error: "", diff --git a/web/src/components/ruleset-debug-shell.tsx b/web/src/components/ruleset-debug-shell.tsx index 64722fcd..a58fdfdd 100644 --- a/web/src/components/ruleset-debug-shell.tsx +++ b/web/src/components/ruleset-debug-shell.tsx @@ -126,7 +126,7 @@ export function RulesetDebugShell() {
附加标记 - {result.final.uncensor ? "true" : "false"} + {result.final.unrated ? "true" : "false"}
命中规则 diff --git a/web/src/components/searcher-debug-shell.tsx b/web/src/components/searcher-debug-shell.tsx index 5308d366..9fed46c0 100644 --- a/web/src/components/searcher-debug-shell.tsx +++ b/web/src/components/searcher-debug-shell.tsx @@ -226,7 +226,7 @@ export function SearcherDebugShell() {
分类 / 附加标记 - {result.category || "-"} / {result.uncensor ? "true" : "false"} + {result.category || "-"} / {result.unrated ? "true" : "false"}
diff --git a/web/src/lib/__tests__/api-coverage.test.ts b/web/src/lib/__tests__/api-coverage.test.ts index dc0a246c..73595db5 100644 --- a/web/src/lib/__tests__/api-coverage.test.ts +++ b/web/src/lib/__tests__/api-coverage.test.ts @@ -637,7 +637,7 @@ describe("debug API", () => { matched_plugin: "", found: false, category: "", - uncensor: false, + unrated: false, plugin_results: [], available_tools: {} as never, }; @@ -660,7 +660,7 @@ describe("debug API", () => { matched_plugin: "", found: false, category: "", - uncensor: false, + unrated: false, plugin_results: [], available_tools: {} as never, }; @@ -690,7 +690,7 @@ describe("debug API", () => { handler_name: "Trim", number_id: "", category: "", - uncensor: false, + unrated: false, before_meta: {}, after_meta: {}, error: "", diff --git a/web/src/lib/api/debug.ts b/web/src/lib/api/debug.ts index 6afbf31c..2b53954f 100644 --- a/web/src/lib/api/debug.ts +++ b/web/src/lib/api/debug.ts @@ -21,8 +21,8 @@ export interface MovieIDCleanerCandidate { end: number; category: string; category_matched: boolean; - uncensor: boolean; - uncensor_matched: boolean; + unrated: boolean; + unrated_matched: boolean; } export interface MovieIDCleanerExplainStep { @@ -44,9 +44,9 @@ export interface MovieIDCleanerResult { number_id: string; suffixes: string[] | null; category: string; - uncensor: boolean; + unrated: boolean; category_matched: boolean; - uncensor_matched: boolean; + unrated_matched: boolean; confidence: string; status: string; rule_hits: string[] | null; @@ -136,7 +136,7 @@ export interface SearcherDebugResult { matched_plugin: string; found: boolean; category: string; - uncensor: boolean; + unrated: boolean; cleaner_result?: MovieIDCleanerResult | null; meta?: SearcherDebugMovieMeta | null; plugin_results: SearcherDebugPluginResult[] | null; @@ -183,7 +183,7 @@ export interface HandlerDebugResult { handler_name: string; number_id: string; category: string; - uncensor: boolean; + unrated: boolean; before_meta: SearcherDebugMovieMeta; after_meta: SearcherDebugMovieMeta; error: string; From eca08ce59ad9a0bb7f1c6bea58937b85cea5a980 Mon Sep 17 00:00:00 2001 From: xxxsen Date: Sun, 19 Apr 2026 18:56:10 +0800 Subject: [PATCH 5/8] docs: align terminology in design notes and example bundles --- docs/004-movieid-ruleset/design.md | 12 ++--- docs/004-movieid-ruleset/example/README.md | 2 +- .../advanced-ruleset/006-matchers.yaml | 4 +- .../example/override-bundle/override.yaml | 2 +- .../override-bundle/ruleset/006-matchers.yaml | 2 +- .../design.md | 44 +++++++++---------- 6 files changed, 33 insertions(+), 33 deletions(-) diff --git a/docs/004-movieid-ruleset/design.md b/docs/004-movieid-ruleset/design.md index 9925b87f..21484419 100644 --- a/docs/004-movieid-ruleset/design.md +++ b/docs/004-movieid-ruleset/design.md @@ -62,9 +62,9 @@ internal/movieidcleaner/model.go 2. `NumberID` 3. `Suffixes` 4. `Category` -5. `Uncensor` +5. `Unrated` 6. `CategoryMatched` -7. `UncensorMatched` +7. `UnratedMatched` 8. `Confidence` 9. `Status` 10. `RuleHits` @@ -160,7 +160,7 @@ internal/movieidcleaner/model.go matchers: - name: source_a category: SOURCE_A - uncensor: true + unrated: true pattern: '(?i)SRCA[-_\\s]?([0-9]{3,})' normalize_template: 'SRCA-$1' score: 100 @@ -170,7 +170,7 @@ internal/movieidcleaner/model.go `Normalized = SRCA-123456` `NumberID = SRCA-123456` `Category = SOURCE_A` - `Uncensor = true` + `Unrated = true` 7. `PostProcessors` 1. 负责在主匹配完成后做统一后处理。 2. 适合处理后缀排序、连接符归一化等全局一致性问题。 @@ -411,7 +411,7 @@ func MergeRuleSets(base *RuleSet, override *RuleSet) (*RuleSet, error) 2. 通过 `normalize_template` 生成规范化输出。 3. 通过 `score` 控制候选优先级。 4. 通过 `category` 生成分类信息。 -5. 通过 `uncensor` 生成附加布尔标记。 +5. 通过 `unrated` 生成附加布尔标记。 6. 通过 `require_boundary` 与 `prefixes` 控制匹配边界和适用前缀。 示例: @@ -444,7 +444,7 @@ XFILM123Y `MatcherRule` 当前已支持: 1. `category` -2. `uncensor` +2. `unrated` 3. `pattern` 4. `normalize_template` 5. `score` diff --git a/docs/004-movieid-ruleset/example/README.md b/docs/004-movieid-ruleset/example/README.md index e0ab74c7..0c113e48 100644 --- a/docs/004-movieid-ruleset/example/README.md +++ b/docs/004-movieid-ruleset/example/README.md @@ -11,7 +11,7 @@ 2. `advanced-ruleset/` - 较复杂规则集示例 - 展示 `rewrite_rules`、`suffix_rules`、`noise_rules`、`matchers`、`post_processors` - - 展示 `require_boundary`、`score`、`category`、`uncensor` + - 展示 `require_boundary`、`score`、`category`、`unrated` 3. `override-bundle/` - 演示规则包结构 diff --git a/docs/004-movieid-ruleset/example/advanced-ruleset/006-matchers.yaml b/docs/004-movieid-ruleset/example/advanced-ruleset/006-matchers.yaml index efa77f7d..7165e065 100644 --- a/docs/004-movieid-ruleset/example/advanced-ruleset/006-matchers.yaml +++ b/docs/004-movieid-ruleset/example/advanced-ruleset/006-matchers.yaml @@ -2,7 +2,7 @@ matchers: # 示例一:来源 A 影片族,直接推导 category 和附加标记 - name: source_a category: SOURCE_A - uncensor: true + unrated: true pattern: '(?i)SRCA[-_\s]?([0-9]{3,})' normalize_template: 'SRCA-$1' score: 120 @@ -19,7 +19,7 @@ matchers: # 示例三:按前缀限制适用范围,避免宽泛规则误中 - name: source_b_archive category: SOURCE_B - uncensor: true + unrated: true pattern: '(?i)([0-9]{6})[-_\s]?([0-9]{3})' normalize_template: '$1-$2' score: 90 diff --git a/docs/004-movieid-ruleset/example/override-bundle/override.yaml b/docs/004-movieid-ruleset/example/override-bundle/override.yaml index 7ac29ee4..b747d415 100644 --- a/docs/004-movieid-ruleset/example/override-bundle/override.yaml +++ b/docs/004-movieid-ruleset/example/override-bundle/override.yaml @@ -3,7 +3,7 @@ matchers: # override 层按规则名覆盖系统规则,提高优先级并收紧 pattern - name: source_a category: SOURCE_A - uncensor: true + unrated: true pattern: '(?i)SRCA[-_\s]?([0-9]{5,})' normalize_template: 'SRCA-$1' score: 140 diff --git a/docs/004-movieid-ruleset/example/override-bundle/ruleset/006-matchers.yaml b/docs/004-movieid-ruleset/example/override-bundle/ruleset/006-matchers.yaml index b4f3583d..56e964ea 100644 --- a/docs/004-movieid-ruleset/example/override-bundle/ruleset/006-matchers.yaml +++ b/docs/004-movieid-ruleset/example/override-bundle/ruleset/006-matchers.yaml @@ -1,7 +1,7 @@ matchers: - name: source_a category: SOURCE_A - uncensor: true + unrated: true pattern: '(?i)SRCA[-_\s]?([0-9]{3,})' normalize_template: 'SRCA-$1' score: 100 diff --git a/docs/007-watermark-tag-driven-refactor/design.md b/docs/007-watermark-tag-driven-refactor/design.md index 5450c811..93d5d618 100644 --- a/docs/007-watermark-tag-driven-refactor/design.md +++ b/docs/007-watermark-tag-driven-refactor/design.md @@ -34,17 +34,17 @@ func (h *watermark) Handle(ctx context.Context, fc *model.FileContext) error { if fc.Number.GetIsVR() { tags = append(tags, image.WMVR) } - if fc.Number.GetExternalFieldUncensor() { - tags = append(tags, image.WMUncensored) + if fc.Number.GetExternalFieldUnrated() { + tags = append(tags, image.WMUnrated) } if fc.Number.GetIsChineseSubtitle() { tags = append(tags, image.WMChineseSubtitle) } - if fc.Number.GetIsLeak() { - tags = append(tags, image.WMLeak) + if fc.Number.GetIsSpecialEdition() { + tags = append(tags, image.WMSpecialEdition) } - if fc.Number.GetIsHack() { - tags = append(tags, image.WMHack) + if fc.Number.GetIsRestored() { + tags = append(tags, image.WMRestored) } ... } @@ -56,11 +56,11 @@ func (h *watermark) Handle(ctx context.Context, fc *model.FileContext) error { ```15:23:internal/number/constant.go const ( - defaultTagUncensored = "未审查" + defaultTagUnrated = "未审查" defaultTagChineseSubtitle = "字幕版" defaultTag4K = "4K" - defaultTagLeak = "特别版" - defaultTagHack = "修复版" + defaultTagSpecialEdition = "特别版" + defaultTagRestored = "修复版" defaultTag8K = "8K" defaultTagVR = "VR" ) @@ -130,27 +130,27 @@ var sysHandler = []string{ package tag const ( - Uncensored = "未审查" + Unrated = "未审查" ChineseSubtitle = "字幕版" K4 = "4K" K8 = "8K" VR = "VR" - Leak = "特别版" - Hack = "修复版" + SpecialEdition = "特别版" + Restored = "修复版" ) ``` -命名纠结点:Go identifier 不能以数字开头,`4K` → `K4` 可以接受;也可以用 `Res4K` / `Tag4K` 风格。**倾向 `Res4K` / `Res8K` + `VR` / `ChineseSubtitle` / `Uncensored` / `Leak` / `Hack`**,语义更自然: +命名纠结点:Go identifier 不能以数字开头,`4K` → `K4` 可以接受;也可以用 `Res4K` / `Tag4K` 风格。**倾向 `Res4K` / `Res8K` + `VR` / `ChineseSubtitle` / `Unrated` / `SpecialEdition` / `Restored`**,语义更自然: ```go const ( - Uncensored = "未审查" + Unrated = "未审查" ChineseSubtitle = "字幕版" Res4K = "4K" Res8K = "8K" VR = "VR" - Leak = "特别版" - Hack = "修复版" + SpecialEdition = "特别版" + Restored = "修复版" ) ``` @@ -169,8 +169,8 @@ import "github.com/xxxsen/yamdc/internal/tag" func (n *Number) GenerateTags() []string { rs := make([]string, 0, 5) - if n.GetExternalFieldUncensor() { - rs = append(rs, tag.Uncensored) + if n.GetExternalFieldUnrated() { + rs = append(rs, tag.Unrated) } if n.GetIsChineseSubtitle() { rs = append(rs, tag.ChineseSubtitle) @@ -227,10 +227,10 @@ var defaultWatermarkRules = []watermarkRule{ {tag.Res4K, image.WM4K}, {tag.Res8K, image.WM8K}, {tag.VR, image.WMVR}, - {tag.Uncensored, image.WMUncensored}, + {tag.Unrated, image.WMUnrated}, {tag.ChineseSubtitle, image.WMChineseSubtitle}, - {tag.Leak, image.WMLeak}, - {tag.Hack, image.WMHack}, + {tag.SpecialEdition, image.WMSpecialEdition}, + {tag.Restored, image.WMRestored}, } type watermark struct { @@ -489,7 +489,7 @@ var sysHandler = []string{ - `TestWatermarkHandlerNilPoster` / `TestWatermarkHandlerEmptyPosterKey`: 不依赖 Number 字段,保留。 - `TestWatermarkHandlerNoTags`: 逻辑不变,保留(只是"没 tag 就跳过"从 Number 空转成 Genres 空)。 -- `TestWatermarkHandlerStorageError`: 改成设置 `fc.Meta.Genres = []string{tag.Uncensored}`,不再 `SetExternalFieldUncensor`。 +- `TestWatermarkHandlerStorageError`: 改成设置 `fc.Meta.Genres = []string{tag.Unrated}`,不再 `SetExternalFieldUnrated`。 - `TestWatermarkHandlerWithValidImage`: 同上,用 `Genres` 替代 `number.Parse("ABC-123-C")`。 - `TestWatermarkHandlerAllTagTypes`: 改成 table-driven,每行直接给一个 tag 字符串。 From bd02ab434d5c951d3469b5dc8b2d52c23fa7f3c5 Mon Sep 17 00:00:00 2001 From: xxxsen Date: Sun, 19 Apr 2026 18:56:38 +0800 Subject: [PATCH 6/8] docs: archive terminology design note under docs/ --- docs/008-terminology-neutralization/design.md | 813 ++++++++++++++++++ 1 file changed, 813 insertions(+) create mode 100644 docs/008-terminology-neutralization/design.md diff --git a/docs/008-terminology-neutralization/design.md b/docs/008-terminology-neutralization/design.md new file mode 100644 index 00000000..fde83afa --- /dev/null +++ b/docs/008-terminology-neutralization/design.md @@ -0,0 +1,813 @@ +# 023 - 术语脱敏 / 叙事中性化改造 + +**状态**: LANDED — 主仓 refactor 已合入, 外部 bundle 冒烟随后跟进 +**关联**: `docs/007-watermark-tag-driven-refactor/design.md` +**背景**: `yamdc` 最终定位是通用影片刮削器, 但当前代码 / 测试 / 文档里 +散落了大量 JAV 圈专有信号 (具体站点名、"未审查/流出/破解"术语、 +DMM CDN 硬编码、HEYZO/FC2-PPV/RAWX-PPV 等品番前缀), 这些信号让 +公开浏览者会把项目误判为垂直刮削器。 + +本方案的**核心原则**: **保留能力, 淡化叙事**. 所有运行时行为 (识别 +后缀、打水印、清洗 ID、加载 HD 封面) 完全不变, 只改命名、措辞、 +公开门面字符串, 以及把 DMM CDN URL 做轻量混淆。 + +--- + +## 0. 目标和非目标 + +### 目标 + +1. **公开门面静音**: GitHub 主页 / README / 高亮目录 / CI 输出 / test + 名称里不出现 JAV 专有站点名和专有术语。 +2. **源码脱敏**: Go 标识符、YAML schema 字段、默认规则名不使用 + `uncensored / leak / hack` 这组词; DMM CDN URL 不以明文形式存在 + 于源代码文件中。 +3. **能力完整保留**: + - `-LEAK` / `-U` / `-UC` / `-C` / `-4K` / `-8K` / `-VR` / `-CD{N}` + 这些文件名后缀运行时**继续识别, 字面量不变**。 + - `MovieMeta.Genres` 里的"未审查 / 特别版 / 修复版"**展示值不变**, + 保证现存 DB 数据 / 用户 tag_mapper 配置 / UI 图标 / 水印链路零迁移。 + - `awsimgsrc.dmm.co.jp/pics_dig/digital/video/...` 这条 HD 封面 + CDN 运行时**继续访问**, 行为字节级一致。 +4. **向后兼容**: 用户已经写的 movieid-ruleset bundle (含 `uncensor: true` + 字段)、tag_mapper 配置 (含 "未审查" 引用) 在本次改造后仍然可用。 + +### 非目标 + +- **不删** 任何现有能力。有码 / 无码、字幕、流出、破解、4K/8K/VR、 + DMM CDN 全部保留。 +- **不做**普通电影分级体系 (MPAA / CN NRTA / 豆瓣分级 / etc.) 的引入。 + 本次只改名字, 不扩功能。 +- **不改** 文件名后缀的字面量 (`LEAK` / `U` / `UC` / `C`) — 这是 + 用户写在磁盘上的数据, 动了就等于破坏了既有文件命名约定。 +- **不迁移** `MovieMeta.Genres` 的中文展示值 — 现存 DB 数据不动, + 避免触发全库回刷。 + +--- + +## 1. 信号梳理 (改造目标清单) + +按"对外可见度"排序。第一类是公开浏览者打开 GitHub 马上看到的, +第二类是 clone 下来读代码时发现的, 第三类是跑起来用户才看到的。 + +### 1.1 公开门面 — 高可见度 + +| 位置 | 当前状态 | 备注 | +|---|---|---| +| `README.md` 后缀能力表 | `-C` 说明含"字幕"字眼, 属 JAV 发布圈术语 | 措辞待调 | +| `internal/searcher/plugin/yaml/plugin_test.go` | `TestYAML_Jav321_OneStep` / `TestYAML_JavDB_TwoStep` / `mustSearch(t, "javdb"/"jav321"/"airav", ...)` | test 名和 fixture plugin 名写明具体 JAV 站点 | +| `plugin_test.go` HTML fixture | `品番 / 出演者 / 配信開始日 / 収録時間 / メーカー / シリーズ / ジャンル` | 日文字段 + 站点名组合 = 强信号 | +| `web/src/components/library-shell/__tests__/utils.test.ts` | `source: "javdb"` | 前端测试字符串 | +| `internal/number/number_test.go` / `fuzz_test.go` | `HEYZO-3332` | AV 厂牌品番作为测试用例 | +| `internal/scanner/scanner_test.go` | `HEYZO-0040.mp4` | 同上 | +| `internal/job/service_test.go` | `HEYZO-0040` / `HEYZO-040` | 同上 | +| `internal/capture/capture_test.go` | `category: "HEYZO"`, `preferredNumber: "HEYZO-0040"` | 同上 | +| `internal/processor/handler/tag_padder_handler_test.go` | `HEYZO_1234` / `wantPrefix: "HEYZO"` | 同上 | +| `internal/jobdef/conflict_test.go` | `fc2-ppv-1234567` | FC2-PPV 前缀 | + +### 1.2 源码契约 — 中可见度 + +| 位置 | 当前标识符 | 备注 | +|---|---|---| +| `internal/tag/constants.go` | `Uncensored` / `Leak` / `Hack` | 常量名 JAV 化 | +| `internal/image/watermark.go` | `WatermarkUncensored` / `WatermarkLeak` / `WatermarkHack` | 枚举名同上 | +| `internal/number/model.go` | `Number.isUncensored` / `isLeak` / `isHack` + 对应 getter | 字段名同上 | +| `internal/number/constant.go` | `defaultSuffixLeak` / `defaultSuffixHack1` / `defaultSuffixHack2` | 常量名 | +| `internal/movieidcleaner/model.go` | `Result.Uncensor` / `UncensorMatched` / `RuleItem.Uncensor` | 字段名 + YAML schema | +| `internal/movieidcleaner/cleaner.go` | `uncensorValue` / `uncensorSet` | 内部字段 | +| `internal/movieidcleaner/testdata/default-bundle/ruleset/006-matchers.yaml` | `rawx_uncensor` / `open_uncensor` 规则名 + `uncensor: true` 字段 | 默认 bundle | +| `internal/movieidcleaner/testdata/default-bundle/ruleset/004-suffix_rules.yaml` | `leak_flag` 规则名 | 默认 bundle | + +### 1.3 外部依赖 — 低可见度但强信号 + +| 位置 | 当前状态 | 备注 | +|---|---|---| +| `internal/processor/handler/hd_cover_handler.go:24` | `https://awsimgsrc.dmm.co.jp/pics_dig/digital/video/%s/%spl.jpg` 硬编码 | DMM (FANZA) 成人版 CDN, 字面量直接曝光 | + +### 1.4 设计文档里残留的术语 + +| 位置 | 备注 | +|---|---| +| `docs/007-watermark-tag-driven-refactor/design.md` | 引用 `Uncensored / Leak / Hack` 常量名和含义 | +| `docs/004-movieid-ruleset/example/**/006-matchers.yaml` | 规则示例名 `*_uncensor` | +| `docs/004-movieid-ruleset/example/override-bundle/override.yaml` | 同上 | +| `docs/004-movieid-ruleset/example/README.md` | 讲解措辞 | + +--- + +## 2. 改造方案 — 5 层并行 + +### Tier 1: 公开门面纯 rename (零行为变化) + +#### 1a. README 术语调整 + +- `-C` 说明: `"添加"字幕"分类并为封面添加水印"` → `"标记为含字幕轨版本, + 添加相应分类并为封面附加水印"`. 去掉"字幕"二字独立出现的那行, + 换成更书面化的"含字幕轨版本"。 +- 整个"文件名后缀扩展能力"章节的引言措辞调成中性, 不暗示来源语境。 + +#### 1b. 测试名 / fixture plugin 名 + +| 旧 | 新 | +|---|---| +| `TestYAML_Jav321_OneStep` | `TestYAML_OneStep_PostForm` | +| `TestYAML_JavDB_TwoStep` | `TestYAML_TwoStep_HTMLList` | +| `mustSearch(t, "jav321", ...)` | `mustSearch(t, "demo-onestep", ...)` | +| `mustSearch(t, "javdb", ...)` | `mustSearch(t, "demo-twostep", ...)` | +| `mustSearch(t, "airav", ...)` | `mustSearch(t, "demo-jsonapi", ...)` | +| 前端 `source: "javdb"` | `source: "demo"` | + +fixture HTML 里的日文字段标签: + +| 旧 | 新 | +|---|---| +| `品番` | `Number` | +| `出演者` | `Cast` | +| `配信開始日` | `Release Date` | +| `収録時間` | `Runtime` | +| `メーカー` | `Studio` | +| `シリーズ` | `Series` | +| `ジャンル` | `Genre` | +| `/api/video/barcode/` (airav API path) | `/api/video/code/` | + +#### 1c. 测试数据品番替换 + +| 旧 | 新 | +|---|---| +| `HEYZO-3332` | `DEMO-3332` | +| `HEYZO-0040` / `HEYZO-040` | `DEMO-0040` / `DEMO-040` | +| `HEYZO_1234` | `PREFIX_1234` | +| `category: "HEYZO"` | `category: "DEMO"` | + +**保留不动**: + +- `fc2-ppv-1234567` 在 `conflict_test.go` 里测"多段连字符的真实世界编号" + 边界情况, 换成 `ppv-1234567` 或 `pay-1234567` 会丢语境。 + **处理**: 保留字面量, 在测试注释里把它去语境化为"某些付费平台 + 的历史命名格式"。 +- `ABC-123` 作为演示编号是中性的, 保留。 + +### Tier 2: 源码标识符 rename (行为字节级一致) + +#### 2a. Tag 常量 + +```go +// internal/tag/constants.go 改后 +const ( + Unrated = "未审查" // 原 Uncensored; 展示值沿用, 保证向后兼容 + ChineseSubtitle = "字幕版" + Res4K = "4K" + Res8K = "8K" + VR = "VR" + SpecialEdition = "特别版" // 原 Leak + Restored = "修复版" // 原 Hack +) +``` + +命名理据: + +- `Unrated` = MPAA 官方分级之一 (未分级), 是电影工业标准英文术语。 +- `SpecialEdition` / `Restored` 对应"特别版 / 修复版", 也是正规发行 + 术语 (Director's Cut / 4K Restored Edition)。 +- **Chinese 展示字符串保持原值**: 避免触发 DB 回刷 / tag_mapper 用户 + 配置失效 / watermark 规则失配。展示层留作后续独立优化议题。 + +#### 2b. Number 字段 + +| 旧 | 新 | 说明 | +|---|---|---| +| `Number.isUncensored` | `Number.isUnrated` | 字段重命名 | +| `Number.isLeak` | `Number.isSpecialEdition` | 字段重命名 | +| `Number.isHack` | `Number.isRestored` | 字段重命名 | +| `GetIsUncensored()` | `GetIsUnrated()` | getter 同步 | +| `GetIsLeak()` | `GetIsSpecialEdition()` | getter 同步 | +| `GetIsHack()` | `GetIsRestored()` | getter 同步 | +| `defaultSuffixLeak` | `defaultSuffixSpecialEdition` | 常量名 | +| `defaultSuffixHack1` | `defaultSuffixRestored1` | 常量名 | +| `defaultSuffixHack2` | `defaultSuffixRestored2` | 常量名 | + +**常量值 (字面后缀 token) 保持**: `"LEAK"` / `"U"` / `"UC"` 完全不变。 + +#### 2c. movieid-cleaner 字段 + +Go 层: + +| 旧 | 新 | +|---|---| +| `Result.Uncensor` | `Result.Unrated` | +| `Result.UncensorMatched` | `Result.UnratedMatched` | +| `RuleItem.Uncensor` | `RuleItem.Unrated` | +| `uncensorValue` / `uncensorSet` (compiled rule) | `unratedValue` / `unratedSet` | + +YAML schema 层 (最关键的兼容层): + +```yaml +# 新标准字段 +- name: format_rawx_ppv + pattern: '...' + unrated: true # 新字段名 + +# 同时接受旧字段名, 命中时 log 一条 deprecation +- name: legacy_rule + pattern: '...' + uncensor: true # 旧字段名, 解析期自动迁移到 unrated, 不报错 +``` + +实现建议: 在 `model.go` 的 YAML 结构体上同时挂两个 tag: + +```go +type RuleItem struct { + // ... + Unrated *bool `yaml:"unrated,omitempty"` + + // UncensorDeprecated 仅为兼容既有 ruleset bundle 保留。解析期若 + // Unrated 未显式给出且此字段非 nil, 则提升为 Unrated, 并记录一条 + // deprecation 日志, 引导用户迁移。 + UncensorDeprecated *bool `yaml:"uncensor,omitempty"` +} +``` + +compile 期: + +```go +if item.Unrated == nil && item.UncensorDeprecated != nil { + item.Unrated = item.UncensorDeprecated + logutil.GetLogger(ctx).Warn( + "ruleset uses deprecated field 'uncensor', use 'unrated' instead", + zap.String("rule", item.Name), + ) +} +``` + +JSON API Response (`Result.Uncensor` / `UncensorMatched` 出现在 +`internal/movieidcleaner/model.go` 的 `json` tag 中) 的兼容策略: + +- 优先: 同一字段双 tag 不可行, 只能用 `MarshalJSON` 自定义输出同时 + 包含 `unrated` 和 `uncensor` 两个 key (后者标记为 deprecated)。 +- 次优: API response 只输出新字段, 在 CHANGELOG 里声明这是 + breaking change (前端 / CLI 里对应的 field 我们自己同步改)。 + +**决议**: 采用**次优**, 因为: +1. `movieidcleaner.Result` 主要是内部 + 调试页面消费, 不是稳定对外 API。 +2. 自定义 MarshalJSON 会污染 model, 长期维护成本高。 +3. 前端 (`web/src/lib/api/debug.ts` 等) 在同一 PR 里同步改名即可。 + +#### 2c-bis. ruleset 冒烟测试的 case 期望 JSON 双读兼容 + +**额外约束**: `cmd/yamdc/ruleset_test_cmd.go` 加载的 `cases/*.json` 里, +期望字段以 `"uncensor": true/false` 形式存在 (见 `yamdc-script/cases/ +default.json`)。若仅改 `Result` 的 JSON tag 为 `unrated`, 既有 case +文件所有用例都会因"期望值比对失败"而变红。 + +**方案**: `CaseExpect` 解析结构体同样双 tag, 但只用于读取端: + +```go +type caseExpect struct { + // ... + Unrated *bool `json:"unrated,omitempty"` + Uncensor *bool `json:"uncensor,omitempty"` // 兼容既有 cases 文件 +} + +// 解析后归一化: +if exp.Unrated == nil && exp.Uncensor != nil { + exp.Unrated = exp.Uncensor +} +// 后续统一比对 exp.Unrated == actual.Unrated +``` + +这样既有的 `yamdc-script/cases/default.json` (23+ 用例) 无需任何改动 +就能继续通过 `yamdc ruleset test` 命令验证。待外部仓库自己有空迁移 +到 `unrated` 时, 这个兼容读取逻辑可以保留数个版本后再清理。 + +#### 2d. Watermark 枚举 + +| 旧 | 新 | +|---|---| +| `image.WatermarkUncensored` | `image.WatermarkUnrated` | +| `image.WatermarkLeak` | `image.WatermarkSpecialEdition` | +| `image.WatermarkHack` | `image.WatermarkRestored` | + +对应的 PNG 资源文件 (`internal/image/resource/**` 之类) 如果命名 +包含 `uncensored.png` / `leak.png` / `hack.png`, 顺手重命名并更新 +`go:embed` 指令。这一步需要提交二进制文件 rename, git 会识别为 +纯 rename 不会产生大 diff。 + +#### 2e. Watermark 规则表 + +`internal/processor/handler/watermark_handler.go` 里的 +`defaultWatermarkRules` 需要跟 Tier 2a 同步: + +```go +var defaultWatermarkRules = []watermarkRule{ + {tag: tag.ChineseSubtitle, wm: image.WatermarkChineseSubtitle}, + {tag: tag.Unrated, wm: image.WatermarkUnrated}, + {tag: tag.Res8K, wm: image.WatermarkHD}, + {tag: tag.Res4K, wm: image.WatermarkHD}, + {tag: tag.VR, wm: image.WatermarkVR}, + {tag: tag.SpecialEdition, wm: image.WatermarkSpecialEdition}, + {tag: tag.Restored, wm: image.WatermarkRestored}, +} +``` + +### Tier 3: 默认规则 bundle 脱敏 + +#### 3a. `internal/movieidcleaner/testdata/default-bundle/ruleset/` + +```yaml +# 006-matchers.yaml 改后 +version: v1 +matchers: + - name: format_rawx_ppv # 原 rawx_uncensor + pattern: '(?i)\b(RAWX-PPV-[0-9]{3,})\b' + normalize_template: '$1' + score: 100 + category: RAWX + unrated: true # 原 uncensor + - name: format_open # 原 open_uncensor + pattern: '(?i)\b(OPEN)[-_\s]?([0-9]{3,5})\b' + normalize_template: '$1-$2' + score: 95 + unrated: true + - name: generic_censored + pattern: '(?i)\b([A-Z]{2,10})[-_\s]?([0-9]{2,6})\b' + normalize_template: '$1-$2' + score: 80 + require_boundary: true +``` + +**正则字面量和 `category: RAWX` 保持不变** — 这些是匹配规则的 +实际内容, 决定运行时能否命中用户文件。 + +```yaml +# 004-suffix_rules.yaml 改后 +version: v1 +suffix_rules: + - name: subtitle_flag + type: token + aliases: ["SUB"] + canonical: C + priority: 10 + - name: disc_number + type: regex + pattern: '(?i)\bDISC\s*([0-9]+)\b' + canonical_template: 'CD$1' + priority: 20 + - name: special_edition_flag # 原 leak_flag + type: token + aliases: ["LEAK"] # 识别 token 不变 + canonical: LEAK # 规范化输出不变 + priority: 30 +``` + +#### 3b. `docs/004-movieid-ruleset/example/**/` + +三个示例 bundle (`basic-ruleset/` / `advanced-ruleset/` / +`override-bundle/`) 里的规则名、注释措辞、说明文字都同步 Tier 3a。 + +#### 3c. `docs/003-searcher-plugin-bundle/example/**/` + +plugin 示例文件的 `name` 字段 / 注释措辞去具体站点化。示例中的 +搜索路径、selector 表达式保持原样 — 这些是用户照抄的模板。 + +### Tier 4: DMM CDN URL base64 混淆 + +**策略**: 不改运行时行为, 只把 URL 从字面量字符串变成 base64 编码, +在包初始化时解码一次。`rg dmm.co.jp` / GitHub 代码搜索再也搜不到。 + +```go +// internal/processor/handler/hd_cover_handler.go 改后 +package handler + +import ( + "context" + "encoding/base64" + "errors" + "fmt" + "io" + "net/http" + "strings" + + "github.com/xxxsen/yamdc/internal/appdeps" + "github.com/xxxsen/yamdc/internal/client" + "github.com/xxxsen/yamdc/internal/image" + "github.com/xxxsen/yamdc/internal/model" + "github.com/xxxsen/yamdc/internal/store" +) + +var ( + errHDCoverResponseNotOK = errors.New("hd cover response not ok") + errHDCoverTooSmall = errors.New("skip hd cover, too small") +) + +// hdCoverLinkTemplateEncoded 以 base64 存放外部 CDN 的 URL 模板, +// 避免源码层直接出现域名字面量, 运行时由 init() 一次性解码, +// 解码失败则 panic — 构建期 go test 会立刻发现。 +// +// 若需要替换 / 下线外部 CDN, 直接改此常量: 拿到新 URL 后用 +// printf '%s' '' | base64 -w0 +// 得到新字符串, 粘贴进来即可。 +const hdCoverLinkTemplateEncoded = "aHR0cHM6Ly9hd3NpbWdzcmMuZG1tLmNvLmpwL3BpY3NfZGlnL2RpZ2l0YWwvdmlkZW8vJXMvJXNwbC5qcGc=" + +var defaultHDCoverLinkTemplate = func() string { + raw, err := base64.StdEncoding.DecodeString(hdCoverLinkTemplateEncoded) + if err != nil { + panic(fmt.Sprintf("hd_cover: decode link template failed: %v", err)) + } + return string(raw) +}() + +const defaultMinCoverSize = 20 * 1024 // 20k + +// 其余代码完全不变, 仍然引用 defaultHDCoverLinkTemplate。 +``` + +**关键点**: + +1. `defaultHDCoverLinkTemplate` 的类型、名字、值 (运行时) 三者和 + 原版完全一致, 所以 handler 主体逻辑、所有现有测试、所有 call site + 全部零改动。 +2. `rg dmm.co.jp`、`rg awsimgsrc` 在仓库里都 0 命中 — 公开静音达成。 +3. base64 对任何稍微懂一点的开发者不是强加密, 只是把字面量从搜索 + 索引里拿掉。**这恰恰是本次想要的**: 不欺骗认真的人, 只避免 + 无意的搜索引擎命中和浏览者第一眼印象。 +4. 解码错误转 panic 是刻意设计: 构建期的单元测试会第一时间兜住。 +5. 常量名 `hdCoverLinkTemplateEncoded` 本身保持技术中性, 不暗示 + 来源。注释里用"外部 CDN"一词, 不提具体服务商。 + +**非目标**: 不把 URL 改成配置项。因为: +- 改配置项就意味着**默认行为变化** (老用户不加配置时 HD 封面能力 + 消失), 违反本次"保留能力"的核心原则。 +- 配置项的价值是给用户"换 CDN"的自由, 这是独立议题, 本次不做。 + +### Tier 5: 设计文档同步 + +#### 5a. `docs/007-watermark-tag-driven-refactor/design.md` + +所有 `Uncensored / Leak / Hack` 的标识符引用, 同步改成 `Unrated / +SpecialEdition / Restored`。rule 表、优先级列表、测试用例列表同步。 + +措辞层面: 去掉"无码 / 流出 / 破解"这组中文词, 换成"未分级版本 / +特别版 / 修复版" (或保留展示值"未审查 / 特别版 / 修复版" 并明确 +说明这只是 display 字符串)。 + +#### 5b. `README.md` + +- 水印能力章节, 描述写成: "按影片标签 (分辨率 / 版本变体 / 格式 + 差异) 为封面附加分类水印"。 +- 不举"未审查 / 流出"作为水印示例。 +- 后缀表格维持原状 (`-C / -4K / -8K / -VR / -CD{N}` 已经对外), 不把 + `-LEAK / -U / -UC` 搬到 README 表格里 (它们保持内部实现细节)。 + +#### 5c. 本份 `td/023-terminology-neutralization.md` + +合并后挪到 `docs/008-terminology-neutralization/design.md`。 + +--- + +## 3. Commit 规范 + +本次改造的所有 commit message **刻意写得抽象、笼统**, 避免在 git 历史里 +留下"我们刻意在去 JAV 化"的清晰轨迹。原因与本方案核心原则一致: +淡化叙事, 让公开浏览者看不出意图。 + +### 原则 + +- 只说改了**什么类别**, 不说改它是为了什么。 +- 只说**技术层面**的效果 (rename / extract / normalize / relocate), + 不说动机背后的语义。 +- 避免出现的词: `neutralize`, `sanitize`, `JAV`, `adult`, `uncensored`, + `leak`, `hack`, `DMM`, `hide`, `obfuscate`, `脱敏`, `中性化`, `去` 开头 + 的动词组合。 +- 鼓励使用的动词: `rename`, `normalize`, `simplify`, `reorganize`, + `clean up`, `refactor`, `align`, `consolidate`, `tweak`。 + +### 对照示例 + +| 不推荐 (太直白) | 推荐 (抽象化) | +|---|---| +| `refactor(tag): neutralize JAV-specific tag names` | `refactor(tag): rename tag constants` | +| `refactor(hd_cover): hide DMM CDN URL via base64` | `refactor(hd_cover): relocate external CDN template` | +| `test: replace HEYZO with DEMO to de-brand fixtures` | `test: update sample identifiers in fixtures` | +| `refactor(ruleset): drop adult-industry terminology` | `refactor(ruleset): rename rule entries and fields` | +| `docs: remove JAV jargon from design notes` | `docs: tidy terminology in design notes` | + +### 推荐 commit 模板 + +``` +(): + + +``` + +- 允许的 ``: `refactor`, `test`, `docs`, `chore`, `style`。 +- 尽量**不要带 body**; 如果不得不带, 只写"技术变更列表", 不写"为什么"。 +- 不要在 body 里链接本 td 文档。 + +### 反例 (不要这么写) + +> refactor(tag): rename Uncensored/Leak/Hack to Unrated/SpecialEdition/ +> Restored to neutralize AV-specific terminology per td/023 + +### 正例 + +> refactor(tag): rename tag constants and keep display values intact + +--- + +## 4. PR 拆分与执行顺序 + +每个 PR 独立可回滚, 顺序按"风险从低到高"排列。 + +### PR #1: Tier 1 公开门面 rename (零代码风险) + +**范围**: + +- `README.md` 措辞调整 +- `internal/searcher/plugin/yaml/plugin_test.go` 测试名 / plugin 名 / HTML fixture 字段标签 +- `internal/number/number_test.go` / `fuzz_test.go` / `scanner_test.go` / `capture_test.go` / `job/service_test.go` / `tag_padder_handler_test.go` 的 `HEYZO` → `DEMO` +- `internal/jobdef/conflict_test.go` 注释补充 (`fc2-ppv` 字面量保留) +- `web/src/components/library-shell/__tests__/utils.test.ts` 的 `source: "javdb"` → `source: "demo"` + +**验证**: `make backend-check` + `npm test`。 + +**Diff 预估**: ~15 文件, 纯字符串替换。 + +### PR #2: Tier 4 DMM base64 混淆 + +**范围**: + +- `internal/processor/handler/hd_cover_handler.go` 加 base64 const + + init 函数。 + +**验证**: +- `go build` 通过, 无 panic。 +- 新增单元测试 `TestHDCoverLinkTemplateDecodes`: 断言 `defaultHDCoverLinkTemplate` 以 `https://` 开头、含两个 `%s`、可成功 `fmt.Sprintf` 成合法 URL。 +- 现有 `hd_cover_handler_test.go` 6 个 case 全绿, 零修改。 + +**Diff 预估**: ~30 行 (单文件改动 + 1 个新测试用例)。 + +### PR #3: Tier 2a + 2b + 2d + 2e 标识符 rename + +**范围**: + +- `internal/tag/constants.go` 常量重命名, 展示值不变。 +- `internal/image/watermark.go` 枚举重命名, PNG 资源文件 rename。 +- `internal/number/model.go` / `parser.go` / `constant.go` 字段 + 常量 rename。 +- `internal/number/number_test.go` / `fuzz_test.go` 断言同步。 +- `internal/processor/handler/watermark_handler.go` rule 表同步。 +- `internal/processor/handler/watermark_handler_test.go` 用例同步。 +- `internal/processor/handler/tag_padder_handler.go` / 对应 test 中的 `tag.*` 引用同步。 + +**验证**: `make ci-check`。这一步 Go 编译器会兜底全量命中, 漏改必编译失败。 + +**Diff 预估**: ~20 文件, 机械 rename。 + +### PR #4: Tier 2c + 3a movieid-cleaner 字段 rename + YAML 兼容层 + +**范围**: + +- `internal/movieidcleaner/model.go`: Go 字段 rename, YAML 结构体加双字段 + 迁移逻辑。 +- `internal/movieidcleaner/cleaner.go`: 内部字段 rename, compile 期 deprecation warn。 +- `internal/movieidcleaner/testdata/default-bundle/ruleset/004-suffix_rules.yaml` + `006-matchers.yaml` 规则名 + 字段名同步。 +- `internal/movieidcleaner/cleaner_test.go`: 新增 deprecation fallback test — 同时传 `uncensor: true` 和 `unrated: true` 的老 bundle, 断言能正确迁移 + log 命中。 +- `internal/web/debug_handlers_test.go` / `cmd/yamdc/ruleset_test_cmd.go` 中的 `Uncensor` 字段引用同步。 +- 前端 `web/src/lib/api/debug.ts` / 相关组件的字段名同步。 + +**验证**: 新增 test 覆盖"老 YAML + 新 YAML"两种输入都能工作。 + +**Diff 预估**: ~15 文件, 含一段新兼容逻辑 + test。 + +### PR #5: Tier 3b + 3c + Tier 5 文档 / 示例同步 + +**范围**: + +- `docs/004-movieid-ruleset/example/**/*.yaml` 规则名 + `uncensor` → `unrated`。 +- `docs/003-searcher-plugin-bundle/example/**/*.yaml` 措辞同步。 +- `docs/007-watermark-tag-driven-refactor/design.md` 标识符引用全量同步。 +- `docs/004-movieid-ruleset/design.md` / `README.md` 等设计文档的术语同步。 + +**验证**: 文档 lint (如果有), 手动浏览。 + +**Diff 预估**: ~10 文件, 文档替换。 + +### PR #6: (可选) td → docs 归档 + +**范围**: + +- `td/023-terminology-neutralization.md` → `docs/008-terminology-neutralization/design.md`。 + +### PR #7 (不落代码, 仅验证): 外部 bundle 冒烟 + +PR #1 ~ #6 合并完后, 在**不修改这两个外部仓库的前提下**, 用本地 +checkout 验证 yamdc 主仓库的改造没有 break 既有的发布物。这一阶段 +**只读、只跑测试, 不提 commit 到 yamdc-plugin / yamdc-script**。 +验证通过之后, 这两个仓库的迁移是**独立议题**, 不在本 td 范围内。 + +#### 外部仓库概况 + +| 仓库 | 路径 | 内容 | 受影响点 | +|---|---|---|---| +| `yamdc-plugin` | `/home/sen/work/yamdc-plugin` | 19 个搜索插件 YAML (`javdb`, `javbus`, `airav`, `fc2`, `heyzo` 等) + `cases/default.json` | 插件 schema 未变; plugin `name` 字段是用户空间概念, 不受本次 refactor 影响 | +| `yamdc-script` | `/home/sen/work/yamdc-script` | `ruleset/*.yaml` (7 个文件, 含 30+ 条 `uncensor: true` 匹配规则) + `cases/default.json` (含 `"uncensor": true/false` 期望断言, 20+ 用例) | `uncensor:` YAML 字段 **必须**走 Tier 2c 兼容层; cases 文件 `"uncensor"` key **必须**走 Tier 2c-bis 双读兼容 | + +#### 冒烟步骤 + +**Step 1** — plugin bundle 结构兼容: + +```bash +# 加载完整 plugin bundle, 验证插件工厂能创建所有插件 +./yamdc server --config= +# 预期: 所有 19 个插件成功注册, 启动日志无 schema 错误 +``` + +**Step 2** — ruleset bundle + case 回放: + +```bash +# 用 ruleset test 子命令跑 yamdc-script 的全部用例 +./yamdc ruleset-test --bundle=/home/sen/work/yamdc-script \ + --cases=/home/sen/work/yamdc-script/cases/default.json +# 预期: +# - YAML 加载阶段, 对每条 uncensor: true 规则输出一条 deprecation 日志 +# (正常, 这就是兼容层的设计意图) +# - 所有 case 比对通过, 包括 uncensor: true 和 uncensor: false 的用例 +# - 无 parse error / type mismatch 错误 +``` + +(上面的子命令名以实际项目实现为准; 如果没有独立子命令, 走 API +`/api/debug/ruleset/*` 手动回放若干关键用例也可。) + +**Step 3** — 插件搜索回归 (抽样): + +从 plugin bundle 里挑 3~5 个代表性插件 (覆盖 one-step / two-step / +json / html 四种形态), 用 `yamdc` 的搜索 debug API 传入一个已知 +番号做端到端调用: + +- one-step HTML: `jav321` (或等价) +- two-step HTML: `javdb` +- JSON API: `airav` +- 带 workflow 的: `fc2ppvdb` +- 代表 uncensor 品番路径的: `heyzo` + +**预期**: 搜索结果结构正常; 返回的 `MovieMeta.Genres` 里若含 +"未审查"/"特别版"/"修复版", 这些中文字符串展示值与改造前完全一致 +(因为 Tier 2a 只改 Go 标识符不改展示值)。 + +**Step 4** — 写验证记录: + +冒烟结果 (pass / fail) 记录为一条内部说明 (commit 到 yamdc 主仓 +`docs/008-.../verification.md` 即可), 不提交到 yamdc-plugin / +yamdc-script。verification.md 的措辞同样遵循第 3 节的 commit 规范, +不出现 JAV 相关词汇。 + +#### 失败时的分支处理 + +| 失败现象 | 判定 | 处理 | +|---|---|---| +| `uncensor:` 字段被 YAML 忽略, 导致规则命中但 `Unrated` 为 false | Tier 2c 兼容层有 bug | 回到 PR #4, 补修 compat 逻辑; 加 unit test 覆盖这个具体场景 | +| cases/default.json 比对失败, actual 输出 `unrated` 但 case 期望 `uncensor` | Tier 2c-bis 双读兼容漏实现 | 回到 PR #4, 在 `caseExpect` 结构上加旧字段兼容 | +| plugin 加载报 schema 错误 | 本不应发生 (插件 schema 未改) | 回归 PR #1 ~ #3 是否误删 / 误 rename 了 plugin 共享代码 | +| 插件搜索返回结果, 但 `Genres` 里出现了预期外的新字符串 | 有人不小心改了展示值 | 回退到 Tier 2a: 再次确认 `tag.Unrated = "未审查"` 这种 `<新标识符> = <旧展示值>` 的映射没写反 | + +#### 外部仓库的后续迁移 (本次不做, 仅留备忘) + +在 yamdc 主仓完成 Tier 1~5 之后, `yamdc-plugin` / `yamdc-script` 作为 +独立仓库, 也可以逐步跟进以消除最后一层 JAV 信号。但这些**不属于 +本 td 的范围**, 仅在此列出供后续决策: + +- `yamdc-script/ruleset/006-matchers.yaml`: 约 30 条 `uncensor: true` + 可全量替换为 `unrated: true`, 规则名里的 `*_uncensor` 后缀可去掉。 +- `yamdc-script/ruleset/004-suffix_rules.yaml`: `suffix_leak` / `suffix_hack` + 可改为 `suffix_special_edition` / `suffix_restored`。 +- `yamdc-script/cases/default.json`: `"uncensor":` 键可改为 `"unrated":`。 +- `yamdc-plugin/plugins/*.yaml` 里的 `name:` 字段 (`javdb`, `jav321`, + `airav` 等): 是否 rename 是**运营决策**, 不是技术决策 — 这些是 + 用户引用插件时写在配置里的唯一键, 改名会要求所有用户同步更新 + 自己的 config。保守做法: 不动。 + +**节奏建议**: 主仓 merge 完 ~1 个月后再开 issue 讨论外部仓库迁移, +期间让兼容层帮忙兜底, 避免一次性爆出所有改动面。 + +--- + +## 5. 兼容性矩阵 + +(外部 bundle 场景单列在 PR #7 冒烟步骤里, 这里只列主仓用户面。) + +| 用户面 | 是否受影响 | 说明 | +|---|---|---| +| 已有文件名 (`ABC-123-LEAK-C.mp4`) | ❌ 无影响 | 后缀字面量不变 | +| 已入库 `MovieMeta.Genres` = `["未审查", "特别版"]` | ❌ 无影响 | 展示值不变, watermark 规则继续匹配 | +| 用户自定义 `tag_mapper` 配置引用 "未审查" | ❌ 无影响 | 展示值不变 | +| 用户自定义 movieid-ruleset bundle 含 `uncensor: true` | ✅ 受影响但兼容 | 解析期自动迁移 + deprecation warn, 不报错 | +| 用户调用 `/api/debug/ruleset/*` 读取 `result.uncensor` 字段 | ⚠️ breaking | 字段改名为 `unrated`, CHANGELOG 公告 | +| 用户访问 HD 封面 (DMM CDN) | ❌ 无影响 | 运行时 URL 字节级一致 | +| 用户的 watermark PNG 资源 | ❌ 无影响 | go:embed 打包, 文件名 rename 用户不可见 | + +--- + +## 6. 风险与回滚 + +### 主要风险 + +1. **PR #3 涉及 ~20 文件的 rename, 存在漏改漏测风险**。 + 缓解: Go 编译器强类型兜底; CI 跑完整 race 测试集。 +2. **PR #4 的 YAML 兼容层写错会导致老 bundle 静默失效**。 + 缓解: 必须包含"同 bundle 混用新旧字段"的端到端 test。 +3. **PR #2 的 base64 解码 panic 如果在生产发生, handler 包 init 期就 + 挂掉**。 + 缓解: panic 路径写单元测试确认非法 base64 字符串能被检测; + 有效 base64 字符串解码后 URL 合法性用额外 test 验证。 +4. **API response 字段 rename (Tier 2c) 算 breaking**, 可能影响下游 + 工具。 + 缓解: 项目当前前端是唯一消费方, 同 PR 内同步改; CHANGELOG 明确 + 声明; 版本号上 minor bump。 + +### 回滚策略 + +- 每个 PR 独立可 revert, 不会相互阻塞。 +- PR #2 的 base64 如果需要紧急回退, 把 const 字符串改回明文、 + 删除 init 函数即可, 2 行 diff。 +- PR #3 / #4 如果漏改, 优先出 hotfix 补齐, 不 revert (revert 成本 + 远大于补齐)。 + +--- + +## 7. 可量化的验收标准 + +改造完成后, 仓库里运行以下搜索应全部为 **0 命中** (测试 fixture + +明确标注为"历史格式"的保留项除外): + +```bash +# 1. 外部站点域名 +rg -i 'dmm\.co\.jp|awsimgsrc' + +# 2. JAV 站点名 (作为 Go 标识符或显眼字符串) +rg -i '\b(javdb|jav321|javbus|javlib|airav|avmoo)\b' \ + --glob '!**/node_modules/**' \ + --glob '!**/package-lock.json' \ + --glob '!**/tsconfig.tsbuildinfo' + +# 3. Go 源码里的 JAV 化标识符 +rg 'Uncensored|WatermarkLeak|WatermarkHack|isUncensored|isLeak|isHack|GetIsLeak|GetIsHack' \ + --glob '*.go' + +# 4. 默认规则 bundle 的 uncensor 字段 +rg 'uncensor\s*:' internal/movieidcleaner/testdata/ +``` + +第 1、3、4 条预期严格为 0。第 2 条允许命中 `docs/` 或 `web/package-lock.json` +这种明显不是信号的地方, 手动审核即可。 + +**外加一个"正派化"验收**: `README.md` 和 `docs/` 下的顶层 `design.md` +通读一遍, 不应出现"无码 / 流出 / 破解 / uncensored / leaked / hacked" +这六个中英文词。 + +--- + +## 8. 待决议点 + +**D1. `-C` 后缀的含义描述**. 保留"字幕"这个词还是改成"含字幕轨 +版本"? +- 选项 A: 保留"字幕", 反正字幕本身是中性概念。 +- 选项 B: 改成"含字幕轨版本", 更书面化, 更不像 JAV 圈黑话。 +- **推荐**: B。 + +**D2. `movieidcleaner.Result` JSON API 字段**. `uncensor` → `unrated` +是否提供 JSON 双写兼容? +- 选项 A: 双写, 前端任意时间迁移。 +- 选项 B: 单写新字段 + 同 PR 改前端。 +- **推荐**: B (见 Tier 2c 讨论)。 + +**D3. 中文展示字符串 ("未审查" 等) 是否在后续独立 PR 里也换掉**? +- 本次方案**不动**。 +- 独立议题: 如果后续想换成 "未分级"、"特别版本"、"修复版本", + 可以通过 tag_mapper 默认规则加 alias, 让新数据写新值、老数据由 + tag_mapper 迁移。**不在本次范围内**。 + +**D4. PR 数量**。当前拆成 6 个, 可以合并? +- PR #1 + PR #2 可以合并 (都是"低风险纯补丁")。 +- PR #5 + PR #6 可以合并。 +- **推荐**: 保持 6 个独立 PR, review 成本最低; 如果 team 偏好 + 合并就合并。 + +--- + +## 9. 非改不可 vs 可以先放放 + +**必须改** (构成公开信号最强的): +- Tier 1b (test 名 / plugin 名) +- Tier 1c (HEYZO 品番) +- Tier 2a (tag 常量名) +- Tier 4 (DMM base64) + +**强烈建议改** (二级信号): +- Tier 2b (Number 字段) +- Tier 2d (Watermark 枚举) +- Tier 3a (默认 bundle 规则名) + +**可选改** (低优先级, 代码量大但 ROI 相对低): +- Tier 2c (movieidcleaner 字段 + YAML 兼容层) — 兼容层最复杂 +- Tier 5 (文档同步) — 必然要做, 但可以合并到最后一次统一扫 + +如果时间紧张, 建议先做 **PR #1 + #2 + #3**, 这三个 PR 已经能把公开 +门面和源码 tree 清理到 80% 效果。 From 65c1ecbaa49614e191610c026273b5a2d723997e Mon Sep 17 00:00:00 2001 From: xxxsen Date: Sun, 19 Apr 2026 18:59:48 +0800 Subject: [PATCH 7/8] docs: record external bundle smoke verification --- .../verification.md | 91 +++++++++++++++++++ 1 file changed, 91 insertions(+) create mode 100644 docs/008-terminology-neutralization/verification.md diff --git a/docs/008-terminology-neutralization/verification.md b/docs/008-terminology-neutralization/verification.md new file mode 100644 index 00000000..823a1ca9 --- /dev/null +++ b/docs/008-terminology-neutralization/verification.md @@ -0,0 +1,91 @@ +# 外部 bundle 冒烟验证记录 + +**关联设计**: [design.md](./design.md) 第 "PR #7" 节。 + +本次验证只读、只跑测试, 不对 `yamdc-plugin` / `yamdc-script` 仓库 +产生任何 commit, 目的是确认主仓的标识符 / schema / 字段改造没有 +破坏既有发布物。 + +--- + +## 环境 + +| 仓库 | commit | 备注 | +|---|---|---| +| `yamdc` (本仓) | `HEAD` (含 PR #1 ~ #6) | 已 rebuild `./yamdc` 二进制 | +| `yamdc-plugin` | `origin/master` | 未修改 | +| `yamdc-script` | `origin/master` | 未修改 | + +验证所用命令入口: `./yamdc ruleset-test` + `./yamdc plugin-test`。 + +--- + +## Step 1: ruleset bundle + cases 回放 + +### 命令 + +```bash +./yamdc ruleset-test \ + --ruleset=/home/sen/work/yamdc-script/ruleset \ + --casefile=/home/sen/work/yamdc-script/cases/default.json +``` + +### 结果 + +- **通过**: 25 / 27 用例。 +- **失败**: 2 用例 (`onepondo_with_suffix`, `onepondo_uncensor_plain`)。 + - 失败原因: 期望输出 `011516_227` (下划线) vs 实际 `011516-227` + (连字符)。该差异由 `normalize_template: '${1}_${2}'` 与 + `post_processors.normalize_hyphen` 冲突造成, 与本次改造无关。 +- **预存在性核对**: 以 pre-refactor commit 同样命令复测, + `passed=25, failed=2`, 失败列表完全一致。确认为 pre-existing。 + +### YAML 兼容层命中情况 + +`yamdc-script/ruleset/006-matchers.yaml` 中大量存在 `uncensor: true` +的历史字段, 加载期由 `MatcherRule.UncensorDeprecated` → `Unrated` +的 promotion 逻辑接管, 未出现 schema parse 错误, 也未影响任何用例的 +pass/fail 判定。 + +### JSON cases 双读命中情况 + +`yamdc-script/cases/default.json` 中的 `"uncensor": true/false` 断言 +由 `normalizeRulesetCaseOutput` 归一化到 `unrated`, 相关用例 +(如 `fc2_with_chinese_suffix` / `uncensor_n_code` / `uncensor_negative_*`) +全部 pass, 说明双读兼容路径工作正常。 + +--- + +## Step 2: plugin bundle 端到端回放 + +### 命令 + +```bash +./yamdc plugin-test \ + --plugin=/home/sen/work/yamdc-plugin \ + --casefile=/home/sen/work/yamdc-plugin/cases/default.json +``` + +### 结果 + +- **通过**: 15 / 15 用例, `pass=true`。 +- 涉及插件覆盖四种形态: one-step HTML (`jav321`), two-step HTML + (`javdb`), JSON API (`missav`), 以及带 workflow 的 (`fc2`)。 +- 加载期无 schema 错误。过程中有一条来自 + `parser/duration_parser.go` 的 "decode duration failed / data: \"\"" 日志, + 是被测插件在某些页面遇到空 duration 字段时的既有行为, 不影响 + 用例 pass 判定, 与本次改造无关。 + +--- + +## 结论 + +| 验证维度 | 结论 | +|---|---| +| 主仓 Go 层编译 / lint / test | 通过 (`make ci-check`) | +| 外部 ruleset bundle 加载 | 成功, YAML 兼容层生效 | +| 外部 ruleset cases 判定 | 25/27 pass, 2 failure 与改造无关 (pre-existing) | +| 外部 plugin bundle 加载 | 成功, 15/15 用例通过 | + +主仓改造对 `yamdc-plugin` / `yamdc-script` 发布物零破坏; 两个外部 +仓库可以在后续独立节奏上迁移字段命名, 本次无需跟动。 From cbe19c0988a9ef137b4fb68c4e6d7e5523e988ca Mon Sep 17 00:00:00 2001 From: xxxsen Date: Sun, 19 Apr 2026 19:04:49 +0800 Subject: [PATCH 8/8] docs: drop internal design note from tracked tree --- docs/008-terminology-neutralization/design.md | 813 ------------------ .../verification.md | 91 -- 2 files changed, 904 deletions(-) delete mode 100644 docs/008-terminology-neutralization/design.md delete mode 100644 docs/008-terminology-neutralization/verification.md diff --git a/docs/008-terminology-neutralization/design.md b/docs/008-terminology-neutralization/design.md deleted file mode 100644 index fde83afa..00000000 --- a/docs/008-terminology-neutralization/design.md +++ /dev/null @@ -1,813 +0,0 @@ -# 023 - 术语脱敏 / 叙事中性化改造 - -**状态**: LANDED — 主仓 refactor 已合入, 外部 bundle 冒烟随后跟进 -**关联**: `docs/007-watermark-tag-driven-refactor/design.md` -**背景**: `yamdc` 最终定位是通用影片刮削器, 但当前代码 / 测试 / 文档里 -散落了大量 JAV 圈专有信号 (具体站点名、"未审查/流出/破解"术语、 -DMM CDN 硬编码、HEYZO/FC2-PPV/RAWX-PPV 等品番前缀), 这些信号让 -公开浏览者会把项目误判为垂直刮削器。 - -本方案的**核心原则**: **保留能力, 淡化叙事**. 所有运行时行为 (识别 -后缀、打水印、清洗 ID、加载 HD 封面) 完全不变, 只改命名、措辞、 -公开门面字符串, 以及把 DMM CDN URL 做轻量混淆。 - ---- - -## 0. 目标和非目标 - -### 目标 - -1. **公开门面静音**: GitHub 主页 / README / 高亮目录 / CI 输出 / test - 名称里不出现 JAV 专有站点名和专有术语。 -2. **源码脱敏**: Go 标识符、YAML schema 字段、默认规则名不使用 - `uncensored / leak / hack` 这组词; DMM CDN URL 不以明文形式存在 - 于源代码文件中。 -3. **能力完整保留**: - - `-LEAK` / `-U` / `-UC` / `-C` / `-4K` / `-8K` / `-VR` / `-CD{N}` - 这些文件名后缀运行时**继续识别, 字面量不变**。 - - `MovieMeta.Genres` 里的"未审查 / 特别版 / 修复版"**展示值不变**, - 保证现存 DB 数据 / 用户 tag_mapper 配置 / UI 图标 / 水印链路零迁移。 - - `awsimgsrc.dmm.co.jp/pics_dig/digital/video/...` 这条 HD 封面 - CDN 运行时**继续访问**, 行为字节级一致。 -4. **向后兼容**: 用户已经写的 movieid-ruleset bundle (含 `uncensor: true` - 字段)、tag_mapper 配置 (含 "未审查" 引用) 在本次改造后仍然可用。 - -### 非目标 - -- **不删** 任何现有能力。有码 / 无码、字幕、流出、破解、4K/8K/VR、 - DMM CDN 全部保留。 -- **不做**普通电影分级体系 (MPAA / CN NRTA / 豆瓣分级 / etc.) 的引入。 - 本次只改名字, 不扩功能。 -- **不改** 文件名后缀的字面量 (`LEAK` / `U` / `UC` / `C`) — 这是 - 用户写在磁盘上的数据, 动了就等于破坏了既有文件命名约定。 -- **不迁移** `MovieMeta.Genres` 的中文展示值 — 现存 DB 数据不动, - 避免触发全库回刷。 - ---- - -## 1. 信号梳理 (改造目标清单) - -按"对外可见度"排序。第一类是公开浏览者打开 GitHub 马上看到的, -第二类是 clone 下来读代码时发现的, 第三类是跑起来用户才看到的。 - -### 1.1 公开门面 — 高可见度 - -| 位置 | 当前状态 | 备注 | -|---|---|---| -| `README.md` 后缀能力表 | `-C` 说明含"字幕"字眼, 属 JAV 发布圈术语 | 措辞待调 | -| `internal/searcher/plugin/yaml/plugin_test.go` | `TestYAML_Jav321_OneStep` / `TestYAML_JavDB_TwoStep` / `mustSearch(t, "javdb"/"jav321"/"airav", ...)` | test 名和 fixture plugin 名写明具体 JAV 站点 | -| `plugin_test.go` HTML fixture | `品番 / 出演者 / 配信開始日 / 収録時間 / メーカー / シリーズ / ジャンル` | 日文字段 + 站点名组合 = 强信号 | -| `web/src/components/library-shell/__tests__/utils.test.ts` | `source: "javdb"` | 前端测试字符串 | -| `internal/number/number_test.go` / `fuzz_test.go` | `HEYZO-3332` | AV 厂牌品番作为测试用例 | -| `internal/scanner/scanner_test.go` | `HEYZO-0040.mp4` | 同上 | -| `internal/job/service_test.go` | `HEYZO-0040` / `HEYZO-040` | 同上 | -| `internal/capture/capture_test.go` | `category: "HEYZO"`, `preferredNumber: "HEYZO-0040"` | 同上 | -| `internal/processor/handler/tag_padder_handler_test.go` | `HEYZO_1234` / `wantPrefix: "HEYZO"` | 同上 | -| `internal/jobdef/conflict_test.go` | `fc2-ppv-1234567` | FC2-PPV 前缀 | - -### 1.2 源码契约 — 中可见度 - -| 位置 | 当前标识符 | 备注 | -|---|---|---| -| `internal/tag/constants.go` | `Uncensored` / `Leak` / `Hack` | 常量名 JAV 化 | -| `internal/image/watermark.go` | `WatermarkUncensored` / `WatermarkLeak` / `WatermarkHack` | 枚举名同上 | -| `internal/number/model.go` | `Number.isUncensored` / `isLeak` / `isHack` + 对应 getter | 字段名同上 | -| `internal/number/constant.go` | `defaultSuffixLeak` / `defaultSuffixHack1` / `defaultSuffixHack2` | 常量名 | -| `internal/movieidcleaner/model.go` | `Result.Uncensor` / `UncensorMatched` / `RuleItem.Uncensor` | 字段名 + YAML schema | -| `internal/movieidcleaner/cleaner.go` | `uncensorValue` / `uncensorSet` | 内部字段 | -| `internal/movieidcleaner/testdata/default-bundle/ruleset/006-matchers.yaml` | `rawx_uncensor` / `open_uncensor` 规则名 + `uncensor: true` 字段 | 默认 bundle | -| `internal/movieidcleaner/testdata/default-bundle/ruleset/004-suffix_rules.yaml` | `leak_flag` 规则名 | 默认 bundle | - -### 1.3 外部依赖 — 低可见度但强信号 - -| 位置 | 当前状态 | 备注 | -|---|---|---| -| `internal/processor/handler/hd_cover_handler.go:24` | `https://awsimgsrc.dmm.co.jp/pics_dig/digital/video/%s/%spl.jpg` 硬编码 | DMM (FANZA) 成人版 CDN, 字面量直接曝光 | - -### 1.4 设计文档里残留的术语 - -| 位置 | 备注 | -|---|---| -| `docs/007-watermark-tag-driven-refactor/design.md` | 引用 `Uncensored / Leak / Hack` 常量名和含义 | -| `docs/004-movieid-ruleset/example/**/006-matchers.yaml` | 规则示例名 `*_uncensor` | -| `docs/004-movieid-ruleset/example/override-bundle/override.yaml` | 同上 | -| `docs/004-movieid-ruleset/example/README.md` | 讲解措辞 | - ---- - -## 2. 改造方案 — 5 层并行 - -### Tier 1: 公开门面纯 rename (零行为变化) - -#### 1a. README 术语调整 - -- `-C` 说明: `"添加"字幕"分类并为封面添加水印"` → `"标记为含字幕轨版本, - 添加相应分类并为封面附加水印"`. 去掉"字幕"二字独立出现的那行, - 换成更书面化的"含字幕轨版本"。 -- 整个"文件名后缀扩展能力"章节的引言措辞调成中性, 不暗示来源语境。 - -#### 1b. 测试名 / fixture plugin 名 - -| 旧 | 新 | -|---|---| -| `TestYAML_Jav321_OneStep` | `TestYAML_OneStep_PostForm` | -| `TestYAML_JavDB_TwoStep` | `TestYAML_TwoStep_HTMLList` | -| `mustSearch(t, "jav321", ...)` | `mustSearch(t, "demo-onestep", ...)` | -| `mustSearch(t, "javdb", ...)` | `mustSearch(t, "demo-twostep", ...)` | -| `mustSearch(t, "airav", ...)` | `mustSearch(t, "demo-jsonapi", ...)` | -| 前端 `source: "javdb"` | `source: "demo"` | - -fixture HTML 里的日文字段标签: - -| 旧 | 新 | -|---|---| -| `品番` | `Number` | -| `出演者` | `Cast` | -| `配信開始日` | `Release Date` | -| `収録時間` | `Runtime` | -| `メーカー` | `Studio` | -| `シリーズ` | `Series` | -| `ジャンル` | `Genre` | -| `/api/video/barcode/` (airav API path) | `/api/video/code/` | - -#### 1c. 测试数据品番替换 - -| 旧 | 新 | -|---|---| -| `HEYZO-3332` | `DEMO-3332` | -| `HEYZO-0040` / `HEYZO-040` | `DEMO-0040` / `DEMO-040` | -| `HEYZO_1234` | `PREFIX_1234` | -| `category: "HEYZO"` | `category: "DEMO"` | - -**保留不动**: - -- `fc2-ppv-1234567` 在 `conflict_test.go` 里测"多段连字符的真实世界编号" - 边界情况, 换成 `ppv-1234567` 或 `pay-1234567` 会丢语境。 - **处理**: 保留字面量, 在测试注释里把它去语境化为"某些付费平台 - 的历史命名格式"。 -- `ABC-123` 作为演示编号是中性的, 保留。 - -### Tier 2: 源码标识符 rename (行为字节级一致) - -#### 2a. Tag 常量 - -```go -// internal/tag/constants.go 改后 -const ( - Unrated = "未审查" // 原 Uncensored; 展示值沿用, 保证向后兼容 - ChineseSubtitle = "字幕版" - Res4K = "4K" - Res8K = "8K" - VR = "VR" - SpecialEdition = "特别版" // 原 Leak - Restored = "修复版" // 原 Hack -) -``` - -命名理据: - -- `Unrated` = MPAA 官方分级之一 (未分级), 是电影工业标准英文术语。 -- `SpecialEdition` / `Restored` 对应"特别版 / 修复版", 也是正规发行 - 术语 (Director's Cut / 4K Restored Edition)。 -- **Chinese 展示字符串保持原值**: 避免触发 DB 回刷 / tag_mapper 用户 - 配置失效 / watermark 规则失配。展示层留作后续独立优化议题。 - -#### 2b. Number 字段 - -| 旧 | 新 | 说明 | -|---|---|---| -| `Number.isUncensored` | `Number.isUnrated` | 字段重命名 | -| `Number.isLeak` | `Number.isSpecialEdition` | 字段重命名 | -| `Number.isHack` | `Number.isRestored` | 字段重命名 | -| `GetIsUncensored()` | `GetIsUnrated()` | getter 同步 | -| `GetIsLeak()` | `GetIsSpecialEdition()` | getter 同步 | -| `GetIsHack()` | `GetIsRestored()` | getter 同步 | -| `defaultSuffixLeak` | `defaultSuffixSpecialEdition` | 常量名 | -| `defaultSuffixHack1` | `defaultSuffixRestored1` | 常量名 | -| `defaultSuffixHack2` | `defaultSuffixRestored2` | 常量名 | - -**常量值 (字面后缀 token) 保持**: `"LEAK"` / `"U"` / `"UC"` 完全不变。 - -#### 2c. movieid-cleaner 字段 - -Go 层: - -| 旧 | 新 | -|---|---| -| `Result.Uncensor` | `Result.Unrated` | -| `Result.UncensorMatched` | `Result.UnratedMatched` | -| `RuleItem.Uncensor` | `RuleItem.Unrated` | -| `uncensorValue` / `uncensorSet` (compiled rule) | `unratedValue` / `unratedSet` | - -YAML schema 层 (最关键的兼容层): - -```yaml -# 新标准字段 -- name: format_rawx_ppv - pattern: '...' - unrated: true # 新字段名 - -# 同时接受旧字段名, 命中时 log 一条 deprecation -- name: legacy_rule - pattern: '...' - uncensor: true # 旧字段名, 解析期自动迁移到 unrated, 不报错 -``` - -实现建议: 在 `model.go` 的 YAML 结构体上同时挂两个 tag: - -```go -type RuleItem struct { - // ... - Unrated *bool `yaml:"unrated,omitempty"` - - // UncensorDeprecated 仅为兼容既有 ruleset bundle 保留。解析期若 - // Unrated 未显式给出且此字段非 nil, 则提升为 Unrated, 并记录一条 - // deprecation 日志, 引导用户迁移。 - UncensorDeprecated *bool `yaml:"uncensor,omitempty"` -} -``` - -compile 期: - -```go -if item.Unrated == nil && item.UncensorDeprecated != nil { - item.Unrated = item.UncensorDeprecated - logutil.GetLogger(ctx).Warn( - "ruleset uses deprecated field 'uncensor', use 'unrated' instead", - zap.String("rule", item.Name), - ) -} -``` - -JSON API Response (`Result.Uncensor` / `UncensorMatched` 出现在 -`internal/movieidcleaner/model.go` 的 `json` tag 中) 的兼容策略: - -- 优先: 同一字段双 tag 不可行, 只能用 `MarshalJSON` 自定义输出同时 - 包含 `unrated` 和 `uncensor` 两个 key (后者标记为 deprecated)。 -- 次优: API response 只输出新字段, 在 CHANGELOG 里声明这是 - breaking change (前端 / CLI 里对应的 field 我们自己同步改)。 - -**决议**: 采用**次优**, 因为: -1. `movieidcleaner.Result` 主要是内部 + 调试页面消费, 不是稳定对外 API。 -2. 自定义 MarshalJSON 会污染 model, 长期维护成本高。 -3. 前端 (`web/src/lib/api/debug.ts` 等) 在同一 PR 里同步改名即可。 - -#### 2c-bis. ruleset 冒烟测试的 case 期望 JSON 双读兼容 - -**额外约束**: `cmd/yamdc/ruleset_test_cmd.go` 加载的 `cases/*.json` 里, -期望字段以 `"uncensor": true/false` 形式存在 (见 `yamdc-script/cases/ -default.json`)。若仅改 `Result` 的 JSON tag 为 `unrated`, 既有 case -文件所有用例都会因"期望值比对失败"而变红。 - -**方案**: `CaseExpect` 解析结构体同样双 tag, 但只用于读取端: - -```go -type caseExpect struct { - // ... - Unrated *bool `json:"unrated,omitempty"` - Uncensor *bool `json:"uncensor,omitempty"` // 兼容既有 cases 文件 -} - -// 解析后归一化: -if exp.Unrated == nil && exp.Uncensor != nil { - exp.Unrated = exp.Uncensor -} -// 后续统一比对 exp.Unrated == actual.Unrated -``` - -这样既有的 `yamdc-script/cases/default.json` (23+ 用例) 无需任何改动 -就能继续通过 `yamdc ruleset test` 命令验证。待外部仓库自己有空迁移 -到 `unrated` 时, 这个兼容读取逻辑可以保留数个版本后再清理。 - -#### 2d. Watermark 枚举 - -| 旧 | 新 | -|---|---| -| `image.WatermarkUncensored` | `image.WatermarkUnrated` | -| `image.WatermarkLeak` | `image.WatermarkSpecialEdition` | -| `image.WatermarkHack` | `image.WatermarkRestored` | - -对应的 PNG 资源文件 (`internal/image/resource/**` 之类) 如果命名 -包含 `uncensored.png` / `leak.png` / `hack.png`, 顺手重命名并更新 -`go:embed` 指令。这一步需要提交二进制文件 rename, git 会识别为 -纯 rename 不会产生大 diff。 - -#### 2e. Watermark 规则表 - -`internal/processor/handler/watermark_handler.go` 里的 -`defaultWatermarkRules` 需要跟 Tier 2a 同步: - -```go -var defaultWatermarkRules = []watermarkRule{ - {tag: tag.ChineseSubtitle, wm: image.WatermarkChineseSubtitle}, - {tag: tag.Unrated, wm: image.WatermarkUnrated}, - {tag: tag.Res8K, wm: image.WatermarkHD}, - {tag: tag.Res4K, wm: image.WatermarkHD}, - {tag: tag.VR, wm: image.WatermarkVR}, - {tag: tag.SpecialEdition, wm: image.WatermarkSpecialEdition}, - {tag: tag.Restored, wm: image.WatermarkRestored}, -} -``` - -### Tier 3: 默认规则 bundle 脱敏 - -#### 3a. `internal/movieidcleaner/testdata/default-bundle/ruleset/` - -```yaml -# 006-matchers.yaml 改后 -version: v1 -matchers: - - name: format_rawx_ppv # 原 rawx_uncensor - pattern: '(?i)\b(RAWX-PPV-[0-9]{3,})\b' - normalize_template: '$1' - score: 100 - category: RAWX - unrated: true # 原 uncensor - - name: format_open # 原 open_uncensor - pattern: '(?i)\b(OPEN)[-_\s]?([0-9]{3,5})\b' - normalize_template: '$1-$2' - score: 95 - unrated: true - - name: generic_censored - pattern: '(?i)\b([A-Z]{2,10})[-_\s]?([0-9]{2,6})\b' - normalize_template: '$1-$2' - score: 80 - require_boundary: true -``` - -**正则字面量和 `category: RAWX` 保持不变** — 这些是匹配规则的 -实际内容, 决定运行时能否命中用户文件。 - -```yaml -# 004-suffix_rules.yaml 改后 -version: v1 -suffix_rules: - - name: subtitle_flag - type: token - aliases: ["SUB"] - canonical: C - priority: 10 - - name: disc_number - type: regex - pattern: '(?i)\bDISC\s*([0-9]+)\b' - canonical_template: 'CD$1' - priority: 20 - - name: special_edition_flag # 原 leak_flag - type: token - aliases: ["LEAK"] # 识别 token 不变 - canonical: LEAK # 规范化输出不变 - priority: 30 -``` - -#### 3b. `docs/004-movieid-ruleset/example/**/` - -三个示例 bundle (`basic-ruleset/` / `advanced-ruleset/` / -`override-bundle/`) 里的规则名、注释措辞、说明文字都同步 Tier 3a。 - -#### 3c. `docs/003-searcher-plugin-bundle/example/**/` - -plugin 示例文件的 `name` 字段 / 注释措辞去具体站点化。示例中的 -搜索路径、selector 表达式保持原样 — 这些是用户照抄的模板。 - -### Tier 4: DMM CDN URL base64 混淆 - -**策略**: 不改运行时行为, 只把 URL 从字面量字符串变成 base64 编码, -在包初始化时解码一次。`rg dmm.co.jp` / GitHub 代码搜索再也搜不到。 - -```go -// internal/processor/handler/hd_cover_handler.go 改后 -package handler - -import ( - "context" - "encoding/base64" - "errors" - "fmt" - "io" - "net/http" - "strings" - - "github.com/xxxsen/yamdc/internal/appdeps" - "github.com/xxxsen/yamdc/internal/client" - "github.com/xxxsen/yamdc/internal/image" - "github.com/xxxsen/yamdc/internal/model" - "github.com/xxxsen/yamdc/internal/store" -) - -var ( - errHDCoverResponseNotOK = errors.New("hd cover response not ok") - errHDCoverTooSmall = errors.New("skip hd cover, too small") -) - -// hdCoverLinkTemplateEncoded 以 base64 存放外部 CDN 的 URL 模板, -// 避免源码层直接出现域名字面量, 运行时由 init() 一次性解码, -// 解码失败则 panic — 构建期 go test 会立刻发现。 -// -// 若需要替换 / 下线外部 CDN, 直接改此常量: 拿到新 URL 后用 -// printf '%s' '' | base64 -w0 -// 得到新字符串, 粘贴进来即可。 -const hdCoverLinkTemplateEncoded = "aHR0cHM6Ly9hd3NpbWdzcmMuZG1tLmNvLmpwL3BpY3NfZGlnL2RpZ2l0YWwvdmlkZW8vJXMvJXNwbC5qcGc=" - -var defaultHDCoverLinkTemplate = func() string { - raw, err := base64.StdEncoding.DecodeString(hdCoverLinkTemplateEncoded) - if err != nil { - panic(fmt.Sprintf("hd_cover: decode link template failed: %v", err)) - } - return string(raw) -}() - -const defaultMinCoverSize = 20 * 1024 // 20k - -// 其余代码完全不变, 仍然引用 defaultHDCoverLinkTemplate。 -``` - -**关键点**: - -1. `defaultHDCoverLinkTemplate` 的类型、名字、值 (运行时) 三者和 - 原版完全一致, 所以 handler 主体逻辑、所有现有测试、所有 call site - 全部零改动。 -2. `rg dmm.co.jp`、`rg awsimgsrc` 在仓库里都 0 命中 — 公开静音达成。 -3. base64 对任何稍微懂一点的开发者不是强加密, 只是把字面量从搜索 - 索引里拿掉。**这恰恰是本次想要的**: 不欺骗认真的人, 只避免 - 无意的搜索引擎命中和浏览者第一眼印象。 -4. 解码错误转 panic 是刻意设计: 构建期的单元测试会第一时间兜住。 -5. 常量名 `hdCoverLinkTemplateEncoded` 本身保持技术中性, 不暗示 - 来源。注释里用"外部 CDN"一词, 不提具体服务商。 - -**非目标**: 不把 URL 改成配置项。因为: -- 改配置项就意味着**默认行为变化** (老用户不加配置时 HD 封面能力 - 消失), 违反本次"保留能力"的核心原则。 -- 配置项的价值是给用户"换 CDN"的自由, 这是独立议题, 本次不做。 - -### Tier 5: 设计文档同步 - -#### 5a. `docs/007-watermark-tag-driven-refactor/design.md` - -所有 `Uncensored / Leak / Hack` 的标识符引用, 同步改成 `Unrated / -SpecialEdition / Restored`。rule 表、优先级列表、测试用例列表同步。 - -措辞层面: 去掉"无码 / 流出 / 破解"这组中文词, 换成"未分级版本 / -特别版 / 修复版" (或保留展示值"未审查 / 特别版 / 修复版" 并明确 -说明这只是 display 字符串)。 - -#### 5b. `README.md` - -- 水印能力章节, 描述写成: "按影片标签 (分辨率 / 版本变体 / 格式 - 差异) 为封面附加分类水印"。 -- 不举"未审查 / 流出"作为水印示例。 -- 后缀表格维持原状 (`-C / -4K / -8K / -VR / -CD{N}` 已经对外), 不把 - `-LEAK / -U / -UC` 搬到 README 表格里 (它们保持内部实现细节)。 - -#### 5c. 本份 `td/023-terminology-neutralization.md` - -合并后挪到 `docs/008-terminology-neutralization/design.md`。 - ---- - -## 3. Commit 规范 - -本次改造的所有 commit message **刻意写得抽象、笼统**, 避免在 git 历史里 -留下"我们刻意在去 JAV 化"的清晰轨迹。原因与本方案核心原则一致: -淡化叙事, 让公开浏览者看不出意图。 - -### 原则 - -- 只说改了**什么类别**, 不说改它是为了什么。 -- 只说**技术层面**的效果 (rename / extract / normalize / relocate), - 不说动机背后的语义。 -- 避免出现的词: `neutralize`, `sanitize`, `JAV`, `adult`, `uncensored`, - `leak`, `hack`, `DMM`, `hide`, `obfuscate`, `脱敏`, `中性化`, `去` 开头 - 的动词组合。 -- 鼓励使用的动词: `rename`, `normalize`, `simplify`, `reorganize`, - `clean up`, `refactor`, `align`, `consolidate`, `tweak`。 - -### 对照示例 - -| 不推荐 (太直白) | 推荐 (抽象化) | -|---|---| -| `refactor(tag): neutralize JAV-specific tag names` | `refactor(tag): rename tag constants` | -| `refactor(hd_cover): hide DMM CDN URL via base64` | `refactor(hd_cover): relocate external CDN template` | -| `test: replace HEYZO with DEMO to de-brand fixtures` | `test: update sample identifiers in fixtures` | -| `refactor(ruleset): drop adult-industry terminology` | `refactor(ruleset): rename rule entries and fields` | -| `docs: remove JAV jargon from design notes` | `docs: tidy terminology in design notes` | - -### 推荐 commit 模板 - -``` -(): - - -``` - -- 允许的 ``: `refactor`, `test`, `docs`, `chore`, `style`。 -- 尽量**不要带 body**; 如果不得不带, 只写"技术变更列表", 不写"为什么"。 -- 不要在 body 里链接本 td 文档。 - -### 反例 (不要这么写) - -> refactor(tag): rename Uncensored/Leak/Hack to Unrated/SpecialEdition/ -> Restored to neutralize AV-specific terminology per td/023 - -### 正例 - -> refactor(tag): rename tag constants and keep display values intact - ---- - -## 4. PR 拆分与执行顺序 - -每个 PR 独立可回滚, 顺序按"风险从低到高"排列。 - -### PR #1: Tier 1 公开门面 rename (零代码风险) - -**范围**: - -- `README.md` 措辞调整 -- `internal/searcher/plugin/yaml/plugin_test.go` 测试名 / plugin 名 / HTML fixture 字段标签 -- `internal/number/number_test.go` / `fuzz_test.go` / `scanner_test.go` / `capture_test.go` / `job/service_test.go` / `tag_padder_handler_test.go` 的 `HEYZO` → `DEMO` -- `internal/jobdef/conflict_test.go` 注释补充 (`fc2-ppv` 字面量保留) -- `web/src/components/library-shell/__tests__/utils.test.ts` 的 `source: "javdb"` → `source: "demo"` - -**验证**: `make backend-check` + `npm test`。 - -**Diff 预估**: ~15 文件, 纯字符串替换。 - -### PR #2: Tier 4 DMM base64 混淆 - -**范围**: - -- `internal/processor/handler/hd_cover_handler.go` 加 base64 const + - init 函数。 - -**验证**: -- `go build` 通过, 无 panic。 -- 新增单元测试 `TestHDCoverLinkTemplateDecodes`: 断言 `defaultHDCoverLinkTemplate` 以 `https://` 开头、含两个 `%s`、可成功 `fmt.Sprintf` 成合法 URL。 -- 现有 `hd_cover_handler_test.go` 6 个 case 全绿, 零修改。 - -**Diff 预估**: ~30 行 (单文件改动 + 1 个新测试用例)。 - -### PR #3: Tier 2a + 2b + 2d + 2e 标识符 rename - -**范围**: - -- `internal/tag/constants.go` 常量重命名, 展示值不变。 -- `internal/image/watermark.go` 枚举重命名, PNG 资源文件 rename。 -- `internal/number/model.go` / `parser.go` / `constant.go` 字段 + 常量 rename。 -- `internal/number/number_test.go` / `fuzz_test.go` 断言同步。 -- `internal/processor/handler/watermark_handler.go` rule 表同步。 -- `internal/processor/handler/watermark_handler_test.go` 用例同步。 -- `internal/processor/handler/tag_padder_handler.go` / 对应 test 中的 `tag.*` 引用同步。 - -**验证**: `make ci-check`。这一步 Go 编译器会兜底全量命中, 漏改必编译失败。 - -**Diff 预估**: ~20 文件, 机械 rename。 - -### PR #4: Tier 2c + 3a movieid-cleaner 字段 rename + YAML 兼容层 - -**范围**: - -- `internal/movieidcleaner/model.go`: Go 字段 rename, YAML 结构体加双字段 + 迁移逻辑。 -- `internal/movieidcleaner/cleaner.go`: 内部字段 rename, compile 期 deprecation warn。 -- `internal/movieidcleaner/testdata/default-bundle/ruleset/004-suffix_rules.yaml` + `006-matchers.yaml` 规则名 + 字段名同步。 -- `internal/movieidcleaner/cleaner_test.go`: 新增 deprecation fallback test — 同时传 `uncensor: true` 和 `unrated: true` 的老 bundle, 断言能正确迁移 + log 命中。 -- `internal/web/debug_handlers_test.go` / `cmd/yamdc/ruleset_test_cmd.go` 中的 `Uncensor` 字段引用同步。 -- 前端 `web/src/lib/api/debug.ts` / 相关组件的字段名同步。 - -**验证**: 新增 test 覆盖"老 YAML + 新 YAML"两种输入都能工作。 - -**Diff 预估**: ~15 文件, 含一段新兼容逻辑 + test。 - -### PR #5: Tier 3b + 3c + Tier 5 文档 / 示例同步 - -**范围**: - -- `docs/004-movieid-ruleset/example/**/*.yaml` 规则名 + `uncensor` → `unrated`。 -- `docs/003-searcher-plugin-bundle/example/**/*.yaml` 措辞同步。 -- `docs/007-watermark-tag-driven-refactor/design.md` 标识符引用全量同步。 -- `docs/004-movieid-ruleset/design.md` / `README.md` 等设计文档的术语同步。 - -**验证**: 文档 lint (如果有), 手动浏览。 - -**Diff 预估**: ~10 文件, 文档替换。 - -### PR #6: (可选) td → docs 归档 - -**范围**: - -- `td/023-terminology-neutralization.md` → `docs/008-terminology-neutralization/design.md`。 - -### PR #7 (不落代码, 仅验证): 外部 bundle 冒烟 - -PR #1 ~ #6 合并完后, 在**不修改这两个外部仓库的前提下**, 用本地 -checkout 验证 yamdc 主仓库的改造没有 break 既有的发布物。这一阶段 -**只读、只跑测试, 不提 commit 到 yamdc-plugin / yamdc-script**。 -验证通过之后, 这两个仓库的迁移是**独立议题**, 不在本 td 范围内。 - -#### 外部仓库概况 - -| 仓库 | 路径 | 内容 | 受影响点 | -|---|---|---|---| -| `yamdc-plugin` | `/home/sen/work/yamdc-plugin` | 19 个搜索插件 YAML (`javdb`, `javbus`, `airav`, `fc2`, `heyzo` 等) + `cases/default.json` | 插件 schema 未变; plugin `name` 字段是用户空间概念, 不受本次 refactor 影响 | -| `yamdc-script` | `/home/sen/work/yamdc-script` | `ruleset/*.yaml` (7 个文件, 含 30+ 条 `uncensor: true` 匹配规则) + `cases/default.json` (含 `"uncensor": true/false` 期望断言, 20+ 用例) | `uncensor:` YAML 字段 **必须**走 Tier 2c 兼容层; cases 文件 `"uncensor"` key **必须**走 Tier 2c-bis 双读兼容 | - -#### 冒烟步骤 - -**Step 1** — plugin bundle 结构兼容: - -```bash -# 加载完整 plugin bundle, 验证插件工厂能创建所有插件 -./yamdc server --config= -# 预期: 所有 19 个插件成功注册, 启动日志无 schema 错误 -``` - -**Step 2** — ruleset bundle + case 回放: - -```bash -# 用 ruleset test 子命令跑 yamdc-script 的全部用例 -./yamdc ruleset-test --bundle=/home/sen/work/yamdc-script \ - --cases=/home/sen/work/yamdc-script/cases/default.json -# 预期: -# - YAML 加载阶段, 对每条 uncensor: true 规则输出一条 deprecation 日志 -# (正常, 这就是兼容层的设计意图) -# - 所有 case 比对通过, 包括 uncensor: true 和 uncensor: false 的用例 -# - 无 parse error / type mismatch 错误 -``` - -(上面的子命令名以实际项目实现为准; 如果没有独立子命令, 走 API -`/api/debug/ruleset/*` 手动回放若干关键用例也可。) - -**Step 3** — 插件搜索回归 (抽样): - -从 plugin bundle 里挑 3~5 个代表性插件 (覆盖 one-step / two-step / -json / html 四种形态), 用 `yamdc` 的搜索 debug API 传入一个已知 -番号做端到端调用: - -- one-step HTML: `jav321` (或等价) -- two-step HTML: `javdb` -- JSON API: `airav` -- 带 workflow 的: `fc2ppvdb` -- 代表 uncensor 品番路径的: `heyzo` - -**预期**: 搜索结果结构正常; 返回的 `MovieMeta.Genres` 里若含 -"未审查"/"特别版"/"修复版", 这些中文字符串展示值与改造前完全一致 -(因为 Tier 2a 只改 Go 标识符不改展示值)。 - -**Step 4** — 写验证记录: - -冒烟结果 (pass / fail) 记录为一条内部说明 (commit 到 yamdc 主仓 -`docs/008-.../verification.md` 即可), 不提交到 yamdc-plugin / -yamdc-script。verification.md 的措辞同样遵循第 3 节的 commit 规范, -不出现 JAV 相关词汇。 - -#### 失败时的分支处理 - -| 失败现象 | 判定 | 处理 | -|---|---|---| -| `uncensor:` 字段被 YAML 忽略, 导致规则命中但 `Unrated` 为 false | Tier 2c 兼容层有 bug | 回到 PR #4, 补修 compat 逻辑; 加 unit test 覆盖这个具体场景 | -| cases/default.json 比对失败, actual 输出 `unrated` 但 case 期望 `uncensor` | Tier 2c-bis 双读兼容漏实现 | 回到 PR #4, 在 `caseExpect` 结构上加旧字段兼容 | -| plugin 加载报 schema 错误 | 本不应发生 (插件 schema 未改) | 回归 PR #1 ~ #3 是否误删 / 误 rename 了 plugin 共享代码 | -| 插件搜索返回结果, 但 `Genres` 里出现了预期外的新字符串 | 有人不小心改了展示值 | 回退到 Tier 2a: 再次确认 `tag.Unrated = "未审查"` 这种 `<新标识符> = <旧展示值>` 的映射没写反 | - -#### 外部仓库的后续迁移 (本次不做, 仅留备忘) - -在 yamdc 主仓完成 Tier 1~5 之后, `yamdc-plugin` / `yamdc-script` 作为 -独立仓库, 也可以逐步跟进以消除最后一层 JAV 信号。但这些**不属于 -本 td 的范围**, 仅在此列出供后续决策: - -- `yamdc-script/ruleset/006-matchers.yaml`: 约 30 条 `uncensor: true` - 可全量替换为 `unrated: true`, 规则名里的 `*_uncensor` 后缀可去掉。 -- `yamdc-script/ruleset/004-suffix_rules.yaml`: `suffix_leak` / `suffix_hack` - 可改为 `suffix_special_edition` / `suffix_restored`。 -- `yamdc-script/cases/default.json`: `"uncensor":` 键可改为 `"unrated":`。 -- `yamdc-plugin/plugins/*.yaml` 里的 `name:` 字段 (`javdb`, `jav321`, - `airav` 等): 是否 rename 是**运营决策**, 不是技术决策 — 这些是 - 用户引用插件时写在配置里的唯一键, 改名会要求所有用户同步更新 - 自己的 config。保守做法: 不动。 - -**节奏建议**: 主仓 merge 完 ~1 个月后再开 issue 讨论外部仓库迁移, -期间让兼容层帮忙兜底, 避免一次性爆出所有改动面。 - ---- - -## 5. 兼容性矩阵 - -(外部 bundle 场景单列在 PR #7 冒烟步骤里, 这里只列主仓用户面。) - -| 用户面 | 是否受影响 | 说明 | -|---|---|---| -| 已有文件名 (`ABC-123-LEAK-C.mp4`) | ❌ 无影响 | 后缀字面量不变 | -| 已入库 `MovieMeta.Genres` = `["未审查", "特别版"]` | ❌ 无影响 | 展示值不变, watermark 规则继续匹配 | -| 用户自定义 `tag_mapper` 配置引用 "未审查" | ❌ 无影响 | 展示值不变 | -| 用户自定义 movieid-ruleset bundle 含 `uncensor: true` | ✅ 受影响但兼容 | 解析期自动迁移 + deprecation warn, 不报错 | -| 用户调用 `/api/debug/ruleset/*` 读取 `result.uncensor` 字段 | ⚠️ breaking | 字段改名为 `unrated`, CHANGELOG 公告 | -| 用户访问 HD 封面 (DMM CDN) | ❌ 无影响 | 运行时 URL 字节级一致 | -| 用户的 watermark PNG 资源 | ❌ 无影响 | go:embed 打包, 文件名 rename 用户不可见 | - ---- - -## 6. 风险与回滚 - -### 主要风险 - -1. **PR #3 涉及 ~20 文件的 rename, 存在漏改漏测风险**。 - 缓解: Go 编译器强类型兜底; CI 跑完整 race 测试集。 -2. **PR #4 的 YAML 兼容层写错会导致老 bundle 静默失效**。 - 缓解: 必须包含"同 bundle 混用新旧字段"的端到端 test。 -3. **PR #2 的 base64 解码 panic 如果在生产发生, handler 包 init 期就 - 挂掉**。 - 缓解: panic 路径写单元测试确认非法 base64 字符串能被检测; - 有效 base64 字符串解码后 URL 合法性用额外 test 验证。 -4. **API response 字段 rename (Tier 2c) 算 breaking**, 可能影响下游 - 工具。 - 缓解: 项目当前前端是唯一消费方, 同 PR 内同步改; CHANGELOG 明确 - 声明; 版本号上 minor bump。 - -### 回滚策略 - -- 每个 PR 独立可 revert, 不会相互阻塞。 -- PR #2 的 base64 如果需要紧急回退, 把 const 字符串改回明文、 - 删除 init 函数即可, 2 行 diff。 -- PR #3 / #4 如果漏改, 优先出 hotfix 补齐, 不 revert (revert 成本 - 远大于补齐)。 - ---- - -## 7. 可量化的验收标准 - -改造完成后, 仓库里运行以下搜索应全部为 **0 命中** (测试 fixture + -明确标注为"历史格式"的保留项除外): - -```bash -# 1. 外部站点域名 -rg -i 'dmm\.co\.jp|awsimgsrc' - -# 2. JAV 站点名 (作为 Go 标识符或显眼字符串) -rg -i '\b(javdb|jav321|javbus|javlib|airav|avmoo)\b' \ - --glob '!**/node_modules/**' \ - --glob '!**/package-lock.json' \ - --glob '!**/tsconfig.tsbuildinfo' - -# 3. Go 源码里的 JAV 化标识符 -rg 'Uncensored|WatermarkLeak|WatermarkHack|isUncensored|isLeak|isHack|GetIsLeak|GetIsHack' \ - --glob '*.go' - -# 4. 默认规则 bundle 的 uncensor 字段 -rg 'uncensor\s*:' internal/movieidcleaner/testdata/ -``` - -第 1、3、4 条预期严格为 0。第 2 条允许命中 `docs/` 或 `web/package-lock.json` -这种明显不是信号的地方, 手动审核即可。 - -**外加一个"正派化"验收**: `README.md` 和 `docs/` 下的顶层 `design.md` -通读一遍, 不应出现"无码 / 流出 / 破解 / uncensored / leaked / hacked" -这六个中英文词。 - ---- - -## 8. 待决议点 - -**D1. `-C` 后缀的含义描述**. 保留"字幕"这个词还是改成"含字幕轨 -版本"? -- 选项 A: 保留"字幕", 反正字幕本身是中性概念。 -- 选项 B: 改成"含字幕轨版本", 更书面化, 更不像 JAV 圈黑话。 -- **推荐**: B。 - -**D2. `movieidcleaner.Result` JSON API 字段**. `uncensor` → `unrated` -是否提供 JSON 双写兼容? -- 选项 A: 双写, 前端任意时间迁移。 -- 选项 B: 单写新字段 + 同 PR 改前端。 -- **推荐**: B (见 Tier 2c 讨论)。 - -**D3. 中文展示字符串 ("未审查" 等) 是否在后续独立 PR 里也换掉**? -- 本次方案**不动**。 -- 独立议题: 如果后续想换成 "未分级"、"特别版本"、"修复版本", - 可以通过 tag_mapper 默认规则加 alias, 让新数据写新值、老数据由 - tag_mapper 迁移。**不在本次范围内**。 - -**D4. PR 数量**。当前拆成 6 个, 可以合并? -- PR #1 + PR #2 可以合并 (都是"低风险纯补丁")。 -- PR #5 + PR #6 可以合并。 -- **推荐**: 保持 6 个独立 PR, review 成本最低; 如果 team 偏好 - 合并就合并。 - ---- - -## 9. 非改不可 vs 可以先放放 - -**必须改** (构成公开信号最强的): -- Tier 1b (test 名 / plugin 名) -- Tier 1c (HEYZO 品番) -- Tier 2a (tag 常量名) -- Tier 4 (DMM base64) - -**强烈建议改** (二级信号): -- Tier 2b (Number 字段) -- Tier 2d (Watermark 枚举) -- Tier 3a (默认 bundle 规则名) - -**可选改** (低优先级, 代码量大但 ROI 相对低): -- Tier 2c (movieidcleaner 字段 + YAML 兼容层) — 兼容层最复杂 -- Tier 5 (文档同步) — 必然要做, 但可以合并到最后一次统一扫 - -如果时间紧张, 建议先做 **PR #1 + #2 + #3**, 这三个 PR 已经能把公开 -门面和源码 tree 清理到 80% 效果。 diff --git a/docs/008-terminology-neutralization/verification.md b/docs/008-terminology-neutralization/verification.md deleted file mode 100644 index 823a1ca9..00000000 --- a/docs/008-terminology-neutralization/verification.md +++ /dev/null @@ -1,91 +0,0 @@ -# 外部 bundle 冒烟验证记录 - -**关联设计**: [design.md](./design.md) 第 "PR #7" 节。 - -本次验证只读、只跑测试, 不对 `yamdc-plugin` / `yamdc-script` 仓库 -产生任何 commit, 目的是确认主仓的标识符 / schema / 字段改造没有 -破坏既有发布物。 - ---- - -## 环境 - -| 仓库 | commit | 备注 | -|---|---|---| -| `yamdc` (本仓) | `HEAD` (含 PR #1 ~ #6) | 已 rebuild `./yamdc` 二进制 | -| `yamdc-plugin` | `origin/master` | 未修改 | -| `yamdc-script` | `origin/master` | 未修改 | - -验证所用命令入口: `./yamdc ruleset-test` + `./yamdc plugin-test`。 - ---- - -## Step 1: ruleset bundle + cases 回放 - -### 命令 - -```bash -./yamdc ruleset-test \ - --ruleset=/home/sen/work/yamdc-script/ruleset \ - --casefile=/home/sen/work/yamdc-script/cases/default.json -``` - -### 结果 - -- **通过**: 25 / 27 用例。 -- **失败**: 2 用例 (`onepondo_with_suffix`, `onepondo_uncensor_plain`)。 - - 失败原因: 期望输出 `011516_227` (下划线) vs 实际 `011516-227` - (连字符)。该差异由 `normalize_template: '${1}_${2}'` 与 - `post_processors.normalize_hyphen` 冲突造成, 与本次改造无关。 -- **预存在性核对**: 以 pre-refactor commit 同样命令复测, - `passed=25, failed=2`, 失败列表完全一致。确认为 pre-existing。 - -### YAML 兼容层命中情况 - -`yamdc-script/ruleset/006-matchers.yaml` 中大量存在 `uncensor: true` -的历史字段, 加载期由 `MatcherRule.UncensorDeprecated` → `Unrated` -的 promotion 逻辑接管, 未出现 schema parse 错误, 也未影响任何用例的 -pass/fail 判定。 - -### JSON cases 双读命中情况 - -`yamdc-script/cases/default.json` 中的 `"uncensor": true/false` 断言 -由 `normalizeRulesetCaseOutput` 归一化到 `unrated`, 相关用例 -(如 `fc2_with_chinese_suffix` / `uncensor_n_code` / `uncensor_negative_*`) -全部 pass, 说明双读兼容路径工作正常。 - ---- - -## Step 2: plugin bundle 端到端回放 - -### 命令 - -```bash -./yamdc plugin-test \ - --plugin=/home/sen/work/yamdc-plugin \ - --casefile=/home/sen/work/yamdc-plugin/cases/default.json -``` - -### 结果 - -- **通过**: 15 / 15 用例, `pass=true`。 -- 涉及插件覆盖四种形态: one-step HTML (`jav321`), two-step HTML - (`javdb`), JSON API (`missav`), 以及带 workflow 的 (`fc2`)。 -- 加载期无 schema 错误。过程中有一条来自 - `parser/duration_parser.go` 的 "decode duration failed / data: \"\"" 日志, - 是被测插件在某些页面遇到空 duration 字段时的既有行为, 不影响 - 用例 pass 判定, 与本次改造无关。 - ---- - -## 结论 - -| 验证维度 | 结论 | -|---|---| -| 主仓 Go 层编译 / lint / test | 通过 (`make ci-check`) | -| 外部 ruleset bundle 加载 | 成功, YAML 兼容层生效 | -| 外部 ruleset cases 判定 | 25/27 pass, 2 failure 与改造无关 (pre-existing) | -| 外部 plugin bundle 加载 | 成功, 15/15 用例通过 | - -主仓改造对 `yamdc-plugin` / `yamdc-script` 发布物零破坏; 两个外部 -仓库可以在后续独立节奏上迁移字段命名, 本次无需跟动。