From 2edd8e3babb837eefa24d0a921cc015c49e3763e Mon Sep 17 00:00:00 2001 From: deflis Date: Mon, 2 Nov 2026 01:40:36 +0900 Subject: [PATCH 1/4] =?UTF-8?q?R18=E6=A4=9C=E7=B4=A2=E3=81=AE=E6=9C=AA?= =?UTF-8?q?=E5=AF=BE=E5=BF=9Cuserid=E5=87=BA=E5=8A=9B=E5=AE=9A=E7=BE=A9?= =?UTF-8?q?=E3=81=AE=E5=89=8A=E9=99=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/params.ts | 4 +--- src/search-builder-r18.ts | 2 +- test/builder-types.test.ts | 18 ++++++++++++++++++ 3 files changed, 20 insertions(+), 4 deletions(-) diff --git a/src/params.ts b/src/params.ts index f8d77da..94d0284 100644 --- a/src/params.ts +++ b/src/params.ts @@ -248,8 +248,6 @@ export const R18Fields = { title: "t", /** Nコード */ ncode: "n", - /** 作者のユーザID(数値) */ - userid: "u", /** 作者名 */ writer: "w", /** 小説のあらすじ */ @@ -321,7 +319,7 @@ export const R18Fields = { export type R18Fields = (typeof R18Fields)[keyof Omit< NarouSearchResult, - "novel_type" | "weekly_unique" | "biggenre" | "genre" | "isr15" + "novel_type" | "weekly_unique" | "biggenre" | "genre" | "isr15" | "userid" >]; /** diff --git a/src/search-builder-r18.ts b/src/search-builder-r18.ts index ce24840..8f18562 100644 --- a/src/search-builder-r18.ts +++ b/src/search-builder-r18.ts @@ -15,7 +15,7 @@ import type { export type DefaultR18SearchResultFields = keyof Omit< NarouSearchResult, - "weekly_unique" | "noveltype" | "biggenre" | "genre" | "isr15" | "id" + "weekly_unique" | "noveltype" | "biggenre" | "genre" | "isr15" | "userid" >; /** diff --git a/test/builder-types.test.ts b/test/builder-types.test.ts index bd4f67a..9285426 100644 --- a/test/builder-types.test.ts +++ b/test/builder-types.test.ts @@ -70,6 +70,24 @@ describe.each([ >().toEqualTypeOf(); }); + test("R18検索はuseridを取得項目に含めない", () => { + const r18 = api.searchR18(); + const novel = api.search(); + expectTypeOf< + Awaited>["values"][number] + >().not.toHaveProperty("userid"); + expectTypeOf< + Awaited>["values"][number] + >().toHaveProperty("userid").toEqualTypeOf(); + expectTypeOf(R18Fields).not.toHaveProperty("userid"); + + function invalidUsage() { + // @ts-expect-error R18 APIはユーザーIDの出力に対応していない + api.searchR18().fields(Fields.userid); + } + expectTypeOf(invalidUsage).returns.toBeVoid(); + }); + test("通常検索のoptとfieldsの順序に依存しない型推論", () => { const optFirst = api .search() From f32476e89caa73305919c1e9f37325b652339ff0 Mon Sep 17 00:00:00 2001 From: deflis Date: Mon, 2 Nov 2026 01:40:36 +0900 Subject: [PATCH 2/4] =?UTF-8?q?=E3=83=A6=E3=83=BC=E3=82=B6=E3=83=BC?= =?UTF-8?q?=E6=A4=9C=E7=B4=A2=E3=81=AEname1st=E3=81=AEnull=E8=A8=B1?= =?UTF-8?q?=E5=AE=B9=E3=81=A8=E5=9B=9E=E5=B8=B0=E3=83=86=E3=82=B9=E3=83=88?= =?UTF-8?q?=E3=81=AE=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/narou-search-results.ts | 2 +- test/builder-types.test.ts | 11 +++++++++++ test/user-search.test.ts | 16 ++++++++++++++++ 3 files changed, 28 insertions(+), 1 deletion(-) diff --git a/src/narou-search-results.ts b/src/narou-search-results.ts index 4b58aa0..ad0c195 100644 --- a/src/narou-search-results.ts +++ b/src/narou-search-results.ts @@ -187,7 +187,7 @@ export interface UserSearchResult { * ユーザ名のフリガナの頭文字 * ひらがな以外の場合はnullまたは空文字となります。 */ - name1st: string; + name1st: string | null; /** 小説投稿数 */ novel_cnt: number; /** レビュー投稿数 */ diff --git a/test/builder-types.test.ts b/test/builder-types.test.ts index 9285426..0672b9a 100644 --- a/test/builder-types.test.ts +++ b/test/builder-types.test.ts @@ -88,6 +88,17 @@ describe.each([ expectTypeOf(invalidUsage).returns.toBeVoid(); }); + test("ユーザーのname1stはnullを許容する", () => { + const all = api.searchUser(); + const selected = api.searchUser().fields(UserFields.name1st); + expectTypeOf< + Awaited>["values"][number]["name1st"] + >().toEqualTypeOf(); + expectTypeOf< + Awaited>["values"] + >().toEqualTypeOf(); + }); + test("通常検索のoptとfieldsの順序に依存しない型推論", () => { const optFirst = api .search() diff --git a/test/user-search.test.ts b/test/user-search.test.ts index bda239b..38866ff 100644 --- a/test/user-search.test.ts +++ b/test/user-search.test.ts @@ -326,6 +326,22 @@ describe("UserSearchBuilder", () => { }); describe("name1st", () => { + test.each([null, "", "あ"])( + "ユーザーのname1st=%sを保持する", + async (name1st) => { + server.use( + http.get("https://api.syosetu.com/userapi/api/", ({ request }) => + responseGzipOrJson([{ allcount: 1 }, { name1st }], new URL(request.url)) + ) + ); + const result = await NarouAPI.searchUser() + .gzip(0) + .fields(UserFields.name1st) + .execute(); + expect(result.values).toEqual([{ name1st }]); + } + ); + test("default", async () => { const mockFn = vi.fn(); setupMockHandler(mockFn, ["gzip", "out"]); From 14e36fb81d71ed665802ff8418dbd397cec9d58b Mon Sep 17 00:00:00 2001 From: deflis Date: Mon, 2 Nov 2026 01:40:36 +0900 Subject: [PATCH 3/4] =?UTF-8?q?=E6=8A=BD=E5=87=BA=E3=83=BB=E9=99=A4?= =?UTF-8?q?=E5=A4=96=E6=9D=A1=E4=BB=B6=E3=81=AE=E4=BD=B5=E5=AD=98=E4=BB=95?= =?UTF-8?q?=E6=A7=98=E3=81=AE=E6=98=8E=E6=96=87=E5=8C=96=E3=81=A8=E5=9B=9E?= =?UTF-8?q?=E5=B8=B0=E3=83=86=E3=82=B9=E3=83=88=E3=81=AE=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/search-builder.ts | 4 +++ test/search-builder-r18.test.ts | 35 ++++++++++++++++++++++ test/search-builder.test.ts | 53 +++++++++++++++++++++++++++++++++ 3 files changed, 92 insertions(+) diff --git a/src/search-builder.ts b/src/search-builder.ts index 9b66f31..b4c5d32 100644 --- a/src/search-builder.ts +++ b/src/search-builder.ts @@ -161,6 +161,10 @@ export abstract class SearchBuilderBase< } } +/** + * 要素の抽出条件と除外条件は独立して保持します。 + * 同じ条件にtrueとfalseを指定すると両方をAPIへ送信し、検索結果は0件になります。 + */ export abstract class NovelSearchBuilderBase< T extends SearchResultFieldNames, > extends SearchBuilderBase { diff --git a/test/search-builder-r18.test.ts b/test/search-builder-r18.test.ts index 139f332..dba1800 100644 --- a/test/search-builder-r18.test.ts +++ b/test/search-builder-r18.test.ts @@ -56,6 +56,41 @@ describe("SearchBuilderR18", () => { afterEach(() => server.resetHandlers()); afterAll(() => server.close()); + describe("抽出・除外条件の併存", () => { + test.each([ + ["isBL", "isbl", "notbl"], + ["isGL", "isgl", "notgl"], + ["isZankoku", "iszankoku", "notzankoku"], + ["isTensei", "istensei", "nottensei"], + ["isTenni", "istenni", "nottenni"], + ] as const)( + "%sの両条件を呼び出し順に関係なく保持する", + async (method, include, exclude) => { + const queries: URLSearchParams[] = []; + server.use( + http.get(`https://api.syosetu.com/novel18api/api/`, ({ request }) => { + queries.push(new URL(request.url).searchParams); + return responseGzipOrJson([{ allcount: 0 }], new URL(request.url)); + }) + ); + for (const first of [true, false]) { + const result = await NarouAPI.searchR18() + .gzip(0) + [method](first) + [method](!first) + .execute(); + expect(result.allcount).toBe(0); + expect(result.values).toEqual([]); + } + expect(queries).toHaveLength(2); + for (const query of queries) { + expect(query.get(include)).toBe("1"); + expect(query.get(exclude)).toBe("1"); + } + } + ); + }); + describe("execute", () => { test("execute should call executeNovel18", async () => { const mockFn = vi.fn(); diff --git a/test/search-builder.test.ts b/test/search-builder.test.ts index 63423c7..890aa60 100644 --- a/test/search-builder.test.ts +++ b/test/search-builder.test.ts @@ -60,6 +60,41 @@ describe("SearchBuilder", () => { afterEach(() => server.resetHandlers()); afterAll(() => server.close()); + describe("抽出・除外条件の併存", () => { + test.each([ + ["isBL", "isbl", "notbl"], + ["isGL", "isgl", "notgl"], + ["isZankoku", "iszankoku", "notzankoku"], + ["isTensei", "istensei", "nottensei"], + ["isTenni", "istenni", "nottenni"], + ] as const)( + "%sの両条件を呼び出し順に関係なく保持する", + async (method, include, exclude) => { + const queries: URLSearchParams[] = []; + server.use( + http.get(`https://api.syosetu.com/novelapi/api/`, ({ request }) => { + queries.push(new URL(request.url).searchParams); + return responseGzipOrJson([{ allcount: 0 }], new URL(request.url)); + }) + ); + for (const first of [true, false]) { + const result = await NarouAPI.search() + .gzip(0) + [method](first) + [method](!first) + .execute(); + expect(result.allcount).toBe(0); + expect(result.values).toEqual([]); + } + expect(queries).toHaveLength(2); + for (const query of queries) { + expect(query.get(include)).toBe("1"); + expect(query.get(exclude)).toBe("1"); + } + } + ); + }); + describe("gzip", () => { test("default", async () => { const mockFn = vi.fn(); @@ -1319,6 +1354,24 @@ describe("SearchBuilder", () => { }); describe("isR15", () => { + test("R15の抽出・除外条件を両方保持する", async () => { + const queries: URLSearchParams[] = []; + server.use( + http.get("https://api.syosetu.com/novelapi/api/", ({ request }) => { + queries.push(new URL(request.url).searchParams); + return responseGzipOrJson([{ allcount: 0 }], new URL(request.url)); + }) + ); + for (const first of [true, false]) { + await NarouAPI.search().gzip(0).isR15(first).isR15(!first).execute(); + } + expect(queries).toHaveLength(2); + for (const query of queries) { + expect(query.get("isr15")).toBe("1"); + expect(query.get("notr15")).toBe("1"); + } + }); + test("default", async () => { const mockFn = vi.fn(); setupMockHandler(mockFn, ["gzip", "out"]); From 0c1d96185839efba9e128346438b0d227eb29858 Mon Sep 17 00:00:00 2001 From: deflis Date: Mon, 2 Nov 2026 01:40:36 +0900 Subject: [PATCH 4/4] =?UTF-8?q?=E6=A4=9C=E7=B4=A2=E3=83=9A=E3=83=BC?= =?UTF-8?q?=E3=82=B8=E6=8C=87=E5=AE=9A=E3=81=AE=E6=97=A2=E5=AD=98=E5=8B=95?= =?UTF-8?q?=E4=BD=9C=E3=82=92=E6=A4=9C=E8=A8=BC=E3=81=99=E3=82=8B=E5=9B=9E?= =?UTF-8?q?=E5=B8=B0=E3=83=86=E3=82=B9=E3=83=88=E3=81=AE=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- test/narou-search-results.test.ts | 8 ++++++++ test/search-builder-r18.test.ts | 11 +++++++++++ test/search-builder.test.ts | 8 ++++++-- test/user-search.test.ts | 8 ++++++-- 4 files changed, 31 insertions(+), 4 deletions(-) diff --git a/test/narou-search-results.test.ts b/test/narou-search-results.test.ts index 2888d08..1a381dc 100644 --- a/test/narou-search-results.test.ts +++ b/test/narou-search-results.test.ts @@ -49,6 +49,14 @@ describe('NarouSearchResults', () => { expect(results.values).toEqual(data); }); + it('開始位置がページ境界でなくても従来の計算を維持する', () => { + const results = new NarouSearchResults( + [{ allcount: 100 }], { st: 21, lim: 20 } + ); + expect(results.start).toBe(21); + expect(results.page).toBe(1.05); + }); + it('should handle empty results', () => { const header = { allcount: 0 }; const data: Pick[] = []; diff --git a/test/search-builder-r18.test.ts b/test/search-builder-r18.test.ts index dba1800..1859584 100644 --- a/test/search-builder-r18.test.ts +++ b/test/search-builder-r18.test.ts @@ -91,6 +91,17 @@ describe("SearchBuilderR18", () => { ); }); + describe("page", () => { + test.each([0, 1, 2])("page=%iの従来の開始位置を維持する", async (page) => { + const mockFn = vi.fn(); + setupMockHandler(mockFn, ["st", "lim"], { ncode: "N1234AB" }); + const result = await NarouAPI.searchR18().page(page, 20).execute(); + expect(mockFn).toHaveBeenCalledWith(String(page * 20), "20", 4); + expect(result.start).toBe(page * 20); + expect(result.page).toBe(page); + }); + }); + describe("execute", () => { test("execute should call executeNovel18", async () => { const mockFn = vi.fn(); diff --git a/test/search-builder.test.ts b/test/search-builder.test.ts index 890aa60..fc85de3 100644 --- a/test/search-builder.test.ts +++ b/test/search-builder.test.ts @@ -211,11 +211,13 @@ describe("SearchBuilder", () => { describe("page", () => { describe("default count", () => { - test.each([1, 2, 3, 4, 5] as const)("if page = %i", async (page) => { + test.each([0, 1, 2, 3, 4, 5] as const)("if page = %i", async (page) => { const mockFn = vi.fn(); setupMockHandler(mockFn, ["lim", "st", "gzip", "out"]); const result = await NarouAPI.search().page(page).execute(); + expect(result.start).toBe(page * 20); + expect(result.page).toBe(page); expect(result.allcount).toBe(1); expect(result.length).toBe(1); expect(result.values).toHaveLength(1); @@ -232,11 +234,13 @@ describe("SearchBuilder", () => { }); describe.each([1, 10, 100, 500] as const)("if count = %i", (count) => { - test.each([1, 2, 3, 4, 5] as const)("if page = %i", async (page) => { + test.each([0, 1, 2, 3, 4, 5] as const)("if page = %i", async (page) => { const mockFn = vi.fn(); setupMockHandler(mockFn, ["lim", "st", "gzip", "out"]); const result = await NarouAPI.search().page(page, count).execute(); + expect(result.start).toBe(page * count); + expect(result.page).toBe(page); expect(result.allcount).toBe(1); expect(result.length).toBe(1); expect(result.values).toHaveLength(1); diff --git a/test/user-search.test.ts b/test/user-search.test.ts index 38866ff..c801a5f 100644 --- a/test/user-search.test.ts +++ b/test/user-search.test.ts @@ -169,11 +169,13 @@ describe("UserSearchBuilder", () => { describe("page", () => { describe("default count", () => { - test.each([1, 2, 3, 4, 5] as const)("if page = %i", async (page) => { + test.each([0, 1, 2, 3, 4, 5] as const)("if page = %i", async (page) => { const mockFn = vi.fn(); setupMockHandler(mockFn, ["lim", "st", "gzip", "out"]); const result = await NarouAPI.searchUser().page(page).execute(); + expect(result.start).toBe(page * 20); + expect(result.page).toBe(page); expect(result.allcount).toBe(1); expect(result.length).toBe(1); expect(result.values).toHaveLength(1); @@ -190,11 +192,13 @@ describe("UserSearchBuilder", () => { }); describe.each([1, 10, 100, 500] as const)("if count = %i", (count) => { - test.each([1, 2, 3, 4, 5] as const)("if page = %i", async (page) => { + test.each([0, 1, 2, 3, 4, 5] as const)("if page = %i", async (page) => { const mockFn = vi.fn(); setupMockHandler(mockFn, ["lim", "st", "gzip", "out"]); const result = await NarouAPI.searchUser().page(page, count).execute(); + expect(result.start).toBe(page * count); + expect(result.page).toBe(page); expect(result.allcount).toBe(1); expect(result.length).toBe(1); expect(result.values).toHaveLength(1);