From 89345cde290d3b38081f99fc645b5545229c1051 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8C=AB=E3=83=AD=E3=82=ADP=40deflis?= Date: Wed, 10 Dec 2025 06:14:10 +0900 Subject: [PATCH 1/3] Add fetch options support via execute options bag --- src/index.common.ts | 2 +- src/narou-fetch.ts | 7 ++--- src/narou-jsonp.ts | 5 ++-- src/narou.ts | 56 ++++++++++++++++++++++++++++++--------- src/ranking.ts | 25 +++++++++-------- src/search-builder-r18.ts | 7 +++-- src/search-builder.ts | 8 +++--- src/user-search.ts | 10 +++++-- test/narou-fetch.test.ts | 20 ++++++++++++++ 9 files changed, 103 insertions(+), 37 deletions(-) diff --git a/src/index.common.ts b/src/index.common.ts index ff154ba..1141b43 100644 --- a/src/index.common.ts +++ b/src/index.common.ts @@ -14,7 +14,7 @@ export { export * from "./ranking-history.js"; export * from "./params.js"; -export type { NarouParams } from "./narou.js"; +export type { ExecuteOptions, NarouParams } from "./narou.js"; export * from "./narou-search-results.js"; export type * from "./narou-ranking-results.js"; export * from "./search-builder.js"; diff --git a/src/narou-fetch.ts b/src/narou-fetch.ts index 9c162b5..574a319 100644 --- a/src/narou-fetch.ts +++ b/src/narou-fetch.ts @@ -1,6 +1,6 @@ import { unzipp } from "./util/unzipp.js"; import NarouNovel from "./narou.js"; -import type { NarouParams } from "./narou.js"; +import type { ExecuteOptions, NarouParams } from "./narou.js"; type Fetch = typeof fetch; @@ -18,7 +18,8 @@ export default class NarouNovelFetch extends NarouNovel { protected async execute( params: NarouParams, - endpoint: string + endpoint: string, + options?: ExecuteOptions ): Promise { const query = { ...params, out: "json" }; @@ -36,7 +37,7 @@ export default class NarouNovelFetch extends NarouNovel { } }); - const res = await (this.fetch ?? fetch)(url); + const res = await (this.fetch ?? fetch)(url, options?.fetchOptions); if (!query.gzip) { return (await res.json()) as T; diff --git a/src/narou-jsonp.ts b/src/narou-jsonp.ts index 8f4ad01..04666b2 100644 --- a/src/narou-jsonp.ts +++ b/src/narou-jsonp.ts @@ -1,5 +1,5 @@ import NarouNovel from "./narou.js"; -import type { NarouParams } from "./narou.js"; +import type { ExecuteOptions, NarouParams } from "./narou.js"; import { jsonp } from "./util/jsonp.js"; /** @@ -8,7 +8,8 @@ import { jsonp } from "./util/jsonp.js"; export default class NarouNovelJsonp extends NarouNovel { protected async execute( params: NarouParams, - endpoint: string + endpoint: string, + _options?: ExecuteOptions ): Promise { const query = { ...params, out: "jsonp" }; query.gzip = 0; diff --git a/src/narou.ts b/src/narou.ts index 5ff03ac..ac05b66 100644 --- a/src/narou.ts +++ b/src/narou.ts @@ -21,6 +21,10 @@ export type NarouParams = | RankingHistoryParams | UserSearchParams; +export type ExecuteOptions = { + fetchOptions?: RequestInit; +}; + /** * なろう小説APIへのリクエストを実行する * @class NarouNovel @@ -35,7 +39,8 @@ export default abstract class NarouNovel { */ protected abstract execute( params: NarouParams, - endpoint: string + endpoint: string, + options?: ExecuteOptions ): Promise; /** @@ -46,9 +51,13 @@ export default abstract class NarouNovel { */ protected async executeSearch( params: SearchParams, - endpoint = "https://api.syosetu.com/novelapi/api/" + endpoint = "https://api.syosetu.com/novelapi/api/", + options?: ExecuteOptions ): Promise> { - return new NarouSearchResults(await this.execute(params, endpoint), params); + return new NarouSearchResults( + await this.execute(params, endpoint, options), + params + ); } /** @@ -58,11 +67,13 @@ export default abstract class NarouNovel { * @see https://dev.syosetu.com/man/api/ */ async executeNovel( - params: SearchParams + params: SearchParams, + options?: ExecuteOptions ): Promise> { return await this.executeSearch( params, - "https://api.syosetu.com/novelapi/api/" + "https://api.syosetu.com/novelapi/api/", + options ); } @@ -73,11 +84,13 @@ export default abstract class NarouNovel { * @see https://dev.syosetu.com/xman/api/ */ async executeNovel18( - params: SearchParams + params: SearchParams, + options?: ExecuteOptions ): Promise> { return await this.executeSearch( params, - "https://api.syosetu.com/novel18api/api/" + "https://api.syosetu.com/novel18api/api/", + options ); } @@ -87,8 +100,15 @@ export default abstract class NarouNovel { * @returns ランキング結果 * @see https://dev.syosetu.com/man/rankapi/ */ - async executeRanking(params: RankingParams): Promise { - return await this.execute(params, "https://api.syosetu.com/rank/rankget/"); + async executeRanking( + params: RankingParams, + options?: ExecuteOptions + ): Promise { + return await this.execute( + params, + "https://api.syosetu.com/rank/rankget/", + options + ); } /** @@ -98,9 +118,14 @@ export default abstract class NarouNovel { * @see https://dev.syosetu.com/man/rankinapi/ */ async executeRankingHistory( - params: RankingHistoryParams + params: RankingHistoryParams, + options?: ExecuteOptions ): Promise { - return await this.execute(params, "https://api.syosetu.com/rank/rankin/"); + return await this.execute( + params, + "https://api.syosetu.com/rank/rankin/", + options + ); } /** @@ -110,10 +135,15 @@ export default abstract class NarouNovel { * @see https://dev.syosetu.com/man/userapi/ */ async executeUserSearch( - params: UserSearchParams + params: UserSearchParams, + options?: ExecuteOptions ): Promise> { return new NarouSearchResults( - await this.execute(params, "https://api.syosetu.com/userapi/api/"), + await this.execute( + params, + "https://api.syosetu.com/userapi/api/", + options + ), params ); } diff --git a/src/ranking.ts b/src/ranking.ts index 21ec693..7b77a67 100644 --- a/src/ranking.ts +++ b/src/ranking.ts @@ -10,7 +10,7 @@ import { RankingType, Fields, } from "./params.js"; -import type NarouNovel from "./narou.js"; +import type NarouNovel, { ExecuteOptions } from "./narou.js"; import type { SearchResultFields } from "./narou-search-results.js"; import { addDays, formatDate } from "./util/date.js"; @@ -111,18 +111,18 @@ export default class RankingBuilder { * @returns {Promise} ランキング結果の配列 * @see https://dev.syosetu.com/man/rankapi/#output */ - execute(): Promise { + execute(options?: ExecuteOptions): Promise { const date = formatDate(this.date$); this.set({ rtype: `${date}-${this.type$}` }); - return this.api.executeRanking(this.params as RankingParams); + return this.api.executeRanking(this.params as RankingParams, options); } /** * ランキングAPIを実行し、取得したNコードを元になろう小説APIで詳細情報を取得して結合します。 */ - async executeWithFields(): Promise< - RankingResult[] - >; + async executeWithFields( + options?: ExecuteOptions + ): Promise[]>; /** * ランキングAPIを実行し、取得したNコードを元になろう小説APIで詳細情報を取得して結合します。 * @@ -131,7 +131,8 @@ export default class RankingBuilder { * @returns {Promise>[]>} 詳細情報を含むランキング結果の配列 */ async executeWithFields( - fields: TFields | TFields[] + fields: TFields | TFields[], + options?: ExecuteOptions ): Promise>[]>; /** * ランキングAPIを実行し、取得したNコードを元になろう小説APIで詳細情報を取得して結合します。 @@ -141,7 +142,8 @@ export default class RankingBuilder { */ async executeWithFields( fields: never[], - opt: OptionalFields | OptionalFields[] + opt: OptionalFields | OptionalFields[], + options?: ExecuteOptions ): Promise[]>; /** * ランキングAPIを実行し、取得したNコードを元になろう小説APIで詳細情報を取得して結合します。 @@ -169,9 +171,10 @@ export default class RankingBuilder { TOpt extends OptionalFields | undefined = undefined >( fields: TFields | TFields[] = [], - opt?: TOpt + opt?: TOpt, + options?: ExecuteOptions ): Promise>[]> { - const ranking = await this.execute(); + const ranking = await this.execute(options); const fields$ = Array.isArray(fields) ? fields.length == 0 ? [] @@ -186,7 +189,7 @@ export default class RankingBuilder { } builder.ncode(rankingNcodes); builder.limit(ranking.length); - const result = await builder.execute(); + const result = await builder.execute(options); return ranking.map< RankingResult< diff --git a/src/search-builder-r18.ts b/src/search-builder-r18.ts index 02e961c..b2efd42 100644 --- a/src/search-builder-r18.ts +++ b/src/search-builder-r18.ts @@ -5,6 +5,7 @@ import type { SearchResultR18Fields, SearchResultOptionalFields, } from "./narou-search-results.js"; +import type { ExecuteOptions } from "./narou.js"; import type { R18Site, SearchResultFieldNames, @@ -30,8 +31,10 @@ export default class SearchBuilderR18< * @override * @returns {Promise} 検索結果 */ - execute(): Promise> { - return this.api.executeNovel18(this.params); + execute( + options?: ExecuteOptions + ): Promise> { + return this.api.executeNovel18(this.params, options); } /** diff --git a/src/search-builder.ts b/src/search-builder.ts index 30f871d..87deb64 100644 --- a/src/search-builder.ts +++ b/src/search-builder.ts @@ -1,4 +1,4 @@ -import type NarouNovel from "./narou.js"; +import type NarouNovel, { ExecuteOptions } from "./narou.js"; import type { NarouSearchResult, SearchResultFields, @@ -474,8 +474,10 @@ export abstract class NovelSearchBuilderBase< * なろう小説APIへの検索リクエストを実行する * @returns {Promise} 検索結果 */ - execute(): Promise> { - return this.api.executeNovel(this.params); + execute(options?: ExecuteOptions): Promise< + NarouSearchResults + > { + return this.api.executeNovel(this.params, options); } } diff --git a/src/user-search.ts b/src/user-search.ts index eb607d9..36ad4f6 100644 --- a/src/user-search.ts +++ b/src/user-search.ts @@ -5,6 +5,7 @@ import type { } from "./narou-search-results.js"; import type { UserFields, UserOrder, UserSearchParams } from "./params.js"; import { SearchBuilderBase } from "./search-builder.js"; +import type { ExecuteOptions } from "./narou.js"; /** * なろうユーザ検索API @@ -104,7 +105,12 @@ export default class UserSearchBuilder< * なろう小説APIへのリクエストを実行する * @returns ランキング */ - execute(): Promise> { - return this.api.executeUserSearch(this.params as UserSearchParams); + execute( + options?: ExecuteOptions + ): Promise> { + return this.api.executeUserSearch( + this.params as UserSearchParams, + options + ); } } diff --git a/test/narou-fetch.test.ts b/test/narou-fetch.test.ts index f5578c4..5f6654f 100644 --- a/test/narou-fetch.test.ts +++ b/test/narou-fetch.test.ts @@ -95,6 +95,26 @@ describe('NarouNovelFetch', () => { expect(result).toEqual(mockData); }); + it('should pass fetch options to fetch implementation', async () => { + const customFetchMock = vi.fn().mockResolvedValue({ + json: vi.fn().mockResolvedValue(mockData) + }); + + const narouFetch = new NarouNovelFetch(customFetchMock); + + // @ts-expect-error - Accessing protected method for testing + await narouFetch.execute( + { gzip: 0 }, + 'https://api.example.com', + { fetchOptions: { method: 'POST', headers: { 'X-Test': '1' } } } + ); + + expect(customFetchMock).toHaveBeenCalledWith( + new URL('https://api.example.com/?out=json'), + { method: 'POST', headers: { 'X-Test': '1' } } + ); + }); + it('should set gzip to 5 when undefined', async () => { // URLパラメータをキャプチャするモック const requestSpy = vi.fn(); From 3603f8dbbf0b0c7cdb54550a0e2facac7ebc8c2e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8C=AB=E3=83=AD=E3=82=ADP=40deflis?= Date: Wed, 10 Dec 2025 12:47:47 +0900 Subject: [PATCH 2/3] Mock narou API responses in tests --- src/index.ts | 2 +- src/ranking.ts | 3 +- src/search-builder.ts | 3 +- test/narou.test.ts | 90 ++++++++++++++++++++++++++++++++++++++++++- 4 files changed, 94 insertions(+), 4 deletions(-) diff --git a/src/index.ts b/src/index.ts index f298417..c4a829f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -77,7 +77,7 @@ export async function rankingHistory( if (Array.isArray(result)) { return result.map(formatRankingHistory); } else { - throw new Error(result); + throw result; } } diff --git a/src/ranking.ts b/src/ranking.ts index 7b77a67..5ae64f3 100644 --- a/src/ranking.ts +++ b/src/ranking.ts @@ -10,7 +10,8 @@ import { RankingType, Fields, } from "./params.js"; -import type NarouNovel, { ExecuteOptions } from "./narou.js"; +import type NarouNovel from "./narou.js"; +import type { ExecuteOptions } from "./narou.js"; import type { SearchResultFields } from "./narou-search-results.js"; import { addDays, formatDate } from "./util/date.js"; diff --git a/src/search-builder.ts b/src/search-builder.ts index 87deb64..14f4e76 100644 --- a/src/search-builder.ts +++ b/src/search-builder.ts @@ -1,4 +1,5 @@ -import type NarouNovel, { ExecuteOptions } from "./narou.js"; +import type NarouNovel from "./narou.js"; +import type { ExecuteOptions } from "./narou.js"; import type { NarouSearchResult, SearchResultFields, diff --git a/test/narou.test.ts b/test/narou.test.ts index 65d6ea0..dcba3ca 100644 --- a/test/narou.test.ts +++ b/test/narou.test.ts @@ -1,5 +1,89 @@ import NarouAPI, { Fields, R18Fields, RankingType, UserFields } from "../src"; -import { describe, it, expect, vi } from "vitest"; +import { describe, it, expect, vi, beforeAll, afterEach, afterAll } from "vitest"; +import { setupServer } from "msw/node"; +import { http } from "msw"; +import { responseGzipOrJson } from "./mock"; + +const rankingEntries = Array.from({ length: 300 }, (_, index) => ({ + ncode: index === 0 ? "N5180FZ" : `N${(index + 1).toString().padStart(6, "0")}`, + pt: index === 0 ? 3403 : 1000 - index, + rank: index + 1, +})); + +const rankingHistoryEntries = [ + { rtype: "20200426-d", pt: 308, rank: 169 }, + { rtype: "20200512-w", pt: 2496, rank: 159 }, + { rtype: "20200601-m", pt: 8882, rank: 197 }, + { rtype: "20200801-q", pt: 17562, rank: 282 }, +]; + +const searchResponse = (url: URL) => + responseGzipOrJson( + [ + { allcount: 10 }, + { ncode: "NTEST1", userid: 1 }, + ], + url + ); + +const ncodeSearchResponse = (ncodeParam: string, url: URL) => { + const ncodes = ncodeParam.split("-"); + const results = ncodes.map((code) => ({ + ncode: code.toUpperCase(), + userid: code.toUpperCase() === "N5180FZ" ? 636551 : 123456, + })); + return responseGzipOrJson([{ allcount: ncodes.length }, ...results], url); +}; + +const userSearchResponse = (url: URL) => + responseGzipOrJson( + [ + { allcount: 5 }, + { userid: 1000 }, + ], + url + ); + +const rankingResponse = (url: URL) => responseGzipOrJson(rankingEntries, url); + +const rankingHistoryResponse = (ncode: string, url: URL) => { + if (ncode.toLowerCase() === "n0000a") { + return responseGzipOrJson("Error: Novel not found.", url); + } + return responseGzipOrJson(rankingHistoryEntries, url); +}; + +const server = setupServer( + http.get("https://api.syosetu.com/novelapi/api/", ({ request }) => { + const url = new URL(request.url); + const ncodeParam = url.searchParams.get("ncode"); + if (ncodeParam) { + return ncodeSearchResponse(ncodeParam, url); + } + return searchResponse(url); + }), + http.get("https://api.syosetu.com/novel18api/api/", ({ request }) => { + const url = new URL(request.url); + const ncodeParam = url.searchParams.get("ncode"); + if (ncodeParam) { + return ncodeSearchResponse(ncodeParam, url); + } + return searchResponse(url); + }), + http.get("https://api.syosetu.com/userapi/api/", ({ request }) => { + const url = new URL(request.url); + return userSearchResponse(url); + }), + http.get("https://api.syosetu.com/rank/rankget/", ({ request }) => { + const url = new URL(request.url); + return rankingResponse(url); + }), + http.get("https://api.syosetu.com/rank/rankin/", ({ request }) => { + const url = new URL(request.url); + const ncode = url.searchParams.get("ncode") ?? ""; + return rankingHistoryResponse(ncode, url); + }) +); // MEMO: このファイルのテストは外部APIを利用するため、結果が変わる可能性がある。 // そのため、結果が変わる可能性が少ないものを選択してテストを行っているが、落ちるようになったら修正が必要。 @@ -7,6 +91,10 @@ describe("narou-test", () => { // まれに時間がかかるので30秒に設定 vi.setConfig({ testTimeout: 30000 }); + beforeAll(() => server.listen()); + afterEach(() => server.resetHandlers()); + afterAll(() => server.close()); + describe("search", () => { it("if limit = 1 then length = 1", async () => { const result = await NarouAPI.search() From d8087d15cc862226404b0627ffc21f822a5bf6c5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8C=AB=E3=83=AD=E3=82=ADP=40deflis?= Date: Wed, 10 Dec 2025 12:56:35 +0900 Subject: [PATCH 3/3] Restore narou tests to external API calls --- test/narou.test.ts | 90 +--------------------------------------------- 1 file changed, 1 insertion(+), 89 deletions(-) diff --git a/test/narou.test.ts b/test/narou.test.ts index dcba3ca..65d6ea0 100644 --- a/test/narou.test.ts +++ b/test/narou.test.ts @@ -1,89 +1,5 @@ import NarouAPI, { Fields, R18Fields, RankingType, UserFields } from "../src"; -import { describe, it, expect, vi, beforeAll, afterEach, afterAll } from "vitest"; -import { setupServer } from "msw/node"; -import { http } from "msw"; -import { responseGzipOrJson } from "./mock"; - -const rankingEntries = Array.from({ length: 300 }, (_, index) => ({ - ncode: index === 0 ? "N5180FZ" : `N${(index + 1).toString().padStart(6, "0")}`, - pt: index === 0 ? 3403 : 1000 - index, - rank: index + 1, -})); - -const rankingHistoryEntries = [ - { rtype: "20200426-d", pt: 308, rank: 169 }, - { rtype: "20200512-w", pt: 2496, rank: 159 }, - { rtype: "20200601-m", pt: 8882, rank: 197 }, - { rtype: "20200801-q", pt: 17562, rank: 282 }, -]; - -const searchResponse = (url: URL) => - responseGzipOrJson( - [ - { allcount: 10 }, - { ncode: "NTEST1", userid: 1 }, - ], - url - ); - -const ncodeSearchResponse = (ncodeParam: string, url: URL) => { - const ncodes = ncodeParam.split("-"); - const results = ncodes.map((code) => ({ - ncode: code.toUpperCase(), - userid: code.toUpperCase() === "N5180FZ" ? 636551 : 123456, - })); - return responseGzipOrJson([{ allcount: ncodes.length }, ...results], url); -}; - -const userSearchResponse = (url: URL) => - responseGzipOrJson( - [ - { allcount: 5 }, - { userid: 1000 }, - ], - url - ); - -const rankingResponse = (url: URL) => responseGzipOrJson(rankingEntries, url); - -const rankingHistoryResponse = (ncode: string, url: URL) => { - if (ncode.toLowerCase() === "n0000a") { - return responseGzipOrJson("Error: Novel not found.", url); - } - return responseGzipOrJson(rankingHistoryEntries, url); -}; - -const server = setupServer( - http.get("https://api.syosetu.com/novelapi/api/", ({ request }) => { - const url = new URL(request.url); - const ncodeParam = url.searchParams.get("ncode"); - if (ncodeParam) { - return ncodeSearchResponse(ncodeParam, url); - } - return searchResponse(url); - }), - http.get("https://api.syosetu.com/novel18api/api/", ({ request }) => { - const url = new URL(request.url); - const ncodeParam = url.searchParams.get("ncode"); - if (ncodeParam) { - return ncodeSearchResponse(ncodeParam, url); - } - return searchResponse(url); - }), - http.get("https://api.syosetu.com/userapi/api/", ({ request }) => { - const url = new URL(request.url); - return userSearchResponse(url); - }), - http.get("https://api.syosetu.com/rank/rankget/", ({ request }) => { - const url = new URL(request.url); - return rankingResponse(url); - }), - http.get("https://api.syosetu.com/rank/rankin/", ({ request }) => { - const url = new URL(request.url); - const ncode = url.searchParams.get("ncode") ?? ""; - return rankingHistoryResponse(ncode, url); - }) -); +import { describe, it, expect, vi } from "vitest"; // MEMO: このファイルのテストは外部APIを利用するため、結果が変わる可能性がある。 // そのため、結果が変わる可能性が少ないものを選択してテストを行っているが、落ちるようになったら修正が必要。 @@ -91,10 +7,6 @@ describe("narou-test", () => { // まれに時間がかかるので30秒に設定 vi.setConfig({ testTimeout: 30000 }); - beforeAll(() => server.listen()); - afterEach(() => server.resetHandlers()); - afterAll(() => server.close()); - describe("search", () => { it("if limit = 1 then length = 1", async () => { const result = await NarouAPI.search()