From f14810563dde5b0bdf9026352a118d61baad8c14 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 13:46:27 +0900 Subject: [PATCH] Remove unsupported fetch option note for browser --- README.md | 19 +++++++-- src/narou-fetch.ts | 9 ++-- src/narou-jsonp.ts | 8 ++-- src/narou.ts | 61 ++++++++++++++++++++------- src/ranking.ts | 54 +++++++++++++++++------- src/search-builder-r18.ts | 7 +++- src/search-builder.ts | 5 ++- src/user-search.ts | 10 ++++- test/narou-fetch.test.ts | 17 ++++++++ test/ranking.test.ts | 88 +++++++++++++++++++++++++++++++++++++++ 10 files changed, 232 insertions(+), 46 deletions(-) diff --git a/README.md b/README.md index ea60d9b..ee44627 100644 --- a/README.md +++ b/README.md @@ -49,7 +49,11 @@ const result = await search("異世界") .genre(Genre.RenaiIsekai) .order(Order.FavoriteNovelCount) .limit(10) - .execute(); + .execute({ + headers: { + "user-agent": "example-client" + } + }); console.log(`${result.allcount}件の小説が見つかりました`); ``` @@ -61,6 +65,7 @@ console.log(`${result.allcount}件の小説が見つかりました`); import { search } from "narou/browser"; const result = await search("魔法").execute(); + ``` ## 📖 詳細な API ドキュメント @@ -101,7 +106,11 @@ for (const novel of searchResult.values) { const rankingResult = await ranking() .date(new Date("2023-04-01")) .type(RankingType.Daily) - .execute(); + .execute({ + headers: { + "user-agent": "example-client", + }, + }); for (const novel of rankingResult) { console.log(novel.ncode); @@ -113,7 +122,11 @@ for (const novel of rankingResult) { const rankingResultWithDetail = await ranking() .date(new Date("2023-04-01")) .type(RankingType.Daily) - .executeWithFields(); + .executeWithFields({ + headers: { + "user-agent": "example-client", + }, + }); for (const novel of rankingResultWithDetail) { console.log(novel.ncode); diff --git a/src/narou-fetch.ts b/src/narou-fetch.ts index 9c162b5..e4c448a 100644 --- a/src/narou-fetch.ts +++ b/src/narou-fetch.ts @@ -17,10 +17,11 @@ export default class NarouNovelFetch extends NarouNovel { } protected async execute( - params: NarouParams, - endpoint: string + params: NarouParams | undefined, + endpoint: string, + fetchOptions: RequestInit = {} ): Promise { - const query = { ...params, out: "json" }; + const query = { ...(params ?? {}), out: "json" }; if (query.gzip === undefined) { query.gzip = 5; @@ -36,7 +37,7 @@ export default class NarouNovelFetch extends NarouNovel { } }); - const res = await (this.fetch ?? fetch)(url); + const res = await (this.fetch ?? fetch)(url, fetchOptions); if (!query.gzip) { return (await res.json()) as T; diff --git a/src/narou-jsonp.ts b/src/narou-jsonp.ts index 8f4ad01..17793f2 100644 --- a/src/narou-jsonp.ts +++ b/src/narou-jsonp.ts @@ -7,10 +7,12 @@ import { jsonp } from "./util/jsonp.js"; */ export default class NarouNovelJsonp extends NarouNovel { protected async execute( - params: NarouParams, - endpoint: string + params: NarouParams | undefined, + endpoint: string, + _fetchOptions?: RequestInit ): Promise { - const query = { ...params, out: "jsonp" }; + void _fetchOptions; + const query = { ...(params ?? {}), out: "jsonp" }; query.gzip = 0; const url = new URL(endpoint); diff --git a/src/narou.ts b/src/narou.ts index 5ff03ac..dd82536 100644 --- a/src/narou.ts +++ b/src/narou.ts @@ -31,89 +31,122 @@ export default abstract class NarouNovel { * なろうAPIへのAPIリクエストを実行する * @param params クエリパラメータ * @param endpoint APIエンドポイント + * @param fetchOptions fetchのオプション * @returns 実行結果 */ protected abstract execute( - params: NarouParams, - endpoint: string + params: NarouParams | undefined, + endpoint: string, + fetchOptions?: RequestInit ): Promise; /** * APIへの検索リクエストを実行する * @param params クエリパラメータ * @param endpoint APIエンドポイント + * @param fetchOptions fetchのオプション * @returns 検索結果 */ protected async executeSearch( params: SearchParams, - endpoint = "https://api.syosetu.com/novelapi/api/" + endpoint = "https://api.syosetu.com/novelapi/api/", + fetchOptions?: RequestInit ): Promise> { - return new NarouSearchResults(await this.execute(params, endpoint), params); + return new NarouSearchResults( + await this.execute(params, endpoint, fetchOptions), + params + ); } /** * 小説APIへの検索リクエストを実行する * @param params クエリパラメータ + * @param fetchOptions fetchのオプション * @returns 検索結果 * @see https://dev.syosetu.com/man/api/ */ async executeNovel( - params: SearchParams + params: SearchParams, + fetchOptions?: RequestInit ): Promise> { return await this.executeSearch( params, - "https://api.syosetu.com/novelapi/api/" + "https://api.syosetu.com/novelapi/api/", + fetchOptions ); } /** * R18小説APIへの検索リクエストを実行する * @param params クエリパラメータ + * @param fetchOptions fetchのオプション * @returns 検索結果 * @see https://dev.syosetu.com/xman/api/ */ async executeNovel18( - params: SearchParams + params: SearchParams, + fetchOptions?: RequestInit ): Promise> { return await this.executeSearch( params, - "https://api.syosetu.com/novel18api/api/" + "https://api.syosetu.com/novel18api/api/", + fetchOptions ); } /** * ランキングAPIへのリクエストを実行する * @param params クエリパラメータ + * @param fetchOptions fetchのオプション * @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, + fetchOptions?: RequestInit + ): Promise { + return await this.execute( + params, + "https://api.syosetu.com/rank/rankget/", + fetchOptions + ); } /** * 殿堂入りAPiへのリクエストを実行する * @param params クエリパラメータ + * @param fetchOptions fetchのオプション * @returns ランキング履歴結果 * @see https://dev.syosetu.com/man/rankinapi/ */ async executeRankingHistory( - params: RankingHistoryParams + params: RankingHistoryParams, + fetchOptions?: RequestInit ): Promise { - return await this.execute(params, "https://api.syosetu.com/rank/rankin/"); + return await this.execute( + params, + "https://api.syosetu.com/rank/rankin/", + fetchOptions + ); } /** * ユーザー検索APIへのリクエストを実行する * @param params クエリパラメータ + * @param fetchOptions fetchのオプション * @returns 検索結果 * @see https://dev.syosetu.com/man/userapi/ */ async executeUserSearch( - params: UserSearchParams + params: UserSearchParams, + fetchOptions?: RequestInit ): Promise> { return new NarouSearchResults( - await this.execute(params, "https://api.syosetu.com/userapi/api/"), + await this.execute( + params, + "https://api.syosetu.com/userapi/api/", + fetchOptions + ), params ); } diff --git a/src/ranking.ts b/src/ranking.ts index 21ec693..3bf9ebc 100644 --- a/src/ranking.ts +++ b/src/ranking.ts @@ -14,6 +14,10 @@ import type NarouNovel from "./narou.js"; import type { SearchResultFields } from "./narou-search-results.js"; import { addDays, formatDate } from "./util/date.js"; +function isRequestInit(value: unknown): value is RequestInit { + return typeof value == "object" && value !== null && !Array.isArray(value); +} + /** * なろう小説ランキングAPIのヘルパークラス。 * @@ -108,21 +112,22 @@ export default class RankingBuilder { * 設定されたパラメータに基づき、なろう小説ランキングAPIへのリクエストを実行します。 * * 返される結果には、Nコード、ポイント、順位が含まれます。 + * @param {RequestInit} [fetchOptions] fetch のオプション * @returns {Promise} ランキング結果の配列 * @see https://dev.syosetu.com/man/rankapi/#output */ - execute(): Promise { + execute(fetchOptions?: RequestInit): 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, fetchOptions); } /** * ランキングAPIを実行し、取得したNコードを元になろう小説APIで詳細情報を取得して結合します。 */ - async executeWithFields(): Promise< - RankingResult[] - >; + async executeWithFields( + fetchOptions?: RequestInit + ): Promise[]>; /** * ランキングAPIを実行し、取得したNコードを元になろう小説APIで詳細情報を取得して結合します。 * @@ -131,7 +136,8 @@ export default class RankingBuilder { * @returns {Promise>[]>} 詳細情報を含むランキング結果の配列 */ async executeWithFields( - fields: TFields | TFields[] + fields: TFields | TFields[], + fetchOptions?: RequestInit ): Promise>[]>; /** * ランキングAPIを実行し、取得したNコードを元になろう小説APIで詳細情報を取得して結合します。 @@ -141,7 +147,8 @@ export default class RankingBuilder { */ async executeWithFields( fields: never[], - opt: OptionalFields | OptionalFields[] + opt: OptionalFields | OptionalFields[], + fetchOptions?: RequestInit ): Promise[]>; /** * ランキングAPIを実行し、取得したNコードを元になろう小説APIで詳細情報を取得して結合します。 @@ -153,7 +160,8 @@ export default class RankingBuilder { */ async executeWithFields( fields: TFields | TFields[], - opt: OptionalFields | OptionalFields[] + opt: OptionalFields | OptionalFields[], + fetchOptions?: RequestInit ): Promise | "weekly_unique">[]>; /** * ランキングAPIを実行し、取得したNコードを元になろう小説APIで詳細情報を取得して結合します。 @@ -162,16 +170,30 @@ export default class RankingBuilder { * @template TOpt - オプショナルな取得フィールドの型 * @param fields - 取得するフィールドの配列 (省略時はデフォルトフィールド) * @param opt - オプショナルな取得フィールド (`weekly` など) - * @returns {Promise>[]>} 詳細情報を含むランキング結果の配列 - */ + * @param fetchOptions fetch のオプション + * @returns {Promise>[]>} 詳細情報を含むランキング結果の配列 + */ async executeWithFields< TFields extends Fields, TOpt extends OptionalFields | undefined = undefined >( - fields: TFields | TFields[] = [], - opt?: TOpt + fields: TFields | TFields[] | RequestInit = [], + opt?: TOpt | RequestInit, + fetchOptions?: RequestInit ): Promise>[]> { - const ranking = await this.execute(); + let opt$ = opt; + let fetchOptions$ = fetchOptions; + + if (isRequestInit(fields)) { + fetchOptions$ = fields; + fields = [] as TFields[]; + opt$ = undefined; + } else if (isRequestInit(opt)) { + fetchOptions$ = opt; + opt$ = undefined; + } + + const ranking = await this.execute(fetchOptions$); const fields$ = Array.isArray(fields) ? fields.length == 0 ? [] @@ -181,12 +203,12 @@ export default class RankingBuilder { const rankingNcodes = ranking.map(({ ncode }) => ncode); const builder = new SearchBuilder({}, this.api); builder.fields(fields$); - if (opt) { - builder.opt(opt); + if (opt$) { + builder.opt(opt$); } builder.ncode(rankingNcodes); builder.limit(ranking.length); - const result = await builder.execute(); + const result = await builder.execute(fetchOptions$); return ranking.map< RankingResult< diff --git a/src/search-builder-r18.ts b/src/search-builder-r18.ts index 02e961c..d99a549 100644 --- a/src/search-builder-r18.ts +++ b/src/search-builder-r18.ts @@ -28,10 +28,13 @@ export default class SearchBuilderR18< /** * なろう小説APIへの検索リクエストを実行する * @override + * @param fetchOptions fetchのオプション * @returns {Promise} 検索結果 */ - execute(): Promise> { - return this.api.executeNovel18(this.params); + execute( + fetchOptions?: RequestInit + ): Promise> { + return this.api.executeNovel18(this.params, fetchOptions); } /** diff --git a/src/search-builder.ts b/src/search-builder.ts index 30f871d..fdcaac6 100644 --- a/src/search-builder.ts +++ b/src/search-builder.ts @@ -472,10 +472,11 @@ export abstract class NovelSearchBuilderBase< /** * なろう小説APIへの検索リクエストを実行する + * @param fetchOptions fetchのオプション * @returns {Promise} 検索結果 */ - execute(): Promise> { - return this.api.executeNovel(this.params); + execute(fetchOptions?: RequestInit): Promise> { + return this.api.executeNovel(this.params, fetchOptions); } } diff --git a/src/user-search.ts b/src/user-search.ts index eb607d9..5900b20 100644 --- a/src/user-search.ts +++ b/src/user-search.ts @@ -102,9 +102,15 @@ export default class UserSearchBuilder< /** * なろう小説APIへのリクエストを実行する + * @param fetchOptions fetchのオプション * @returns ランキング */ - execute(): Promise> { - return this.api.executeUserSearch(this.params as UserSearchParams); + execute( + fetchOptions?: RequestInit + ): Promise> { + return this.api.executeUserSearch( + this.params as UserSearchParams, + fetchOptions + ); } } diff --git a/test/narou-fetch.test.ts b/test/narou-fetch.test.ts index f5578c4..2523880 100644 --- a/test/narou-fetch.test.ts +++ b/test/narou-fetch.test.ts @@ -95,6 +95,23 @@ describe('NarouNovelFetch', () => { expect(result).toEqual(mockData); }); + it('should pass fetch options to fetch call', async () => { + const customFetchMock = vi.fn().mockResolvedValue({ + json: vi.fn().mockResolvedValue(mockData) + }); + + const narouFetch = new NarouNovelFetch(customFetchMock); + + const fetchOptions = { + headers: { 'x-custom-header': 'value' } + } satisfies RequestInit; + + // @ts-expect-error - Accessing protected method for testing + await narouFetch.execute({ gzip: 0 }, 'https://api.example.com', fetchOptions); + + expect(customFetchMock).toHaveBeenCalledWith(expect.any(URL), fetchOptions); + }); + it('should set gzip to 5 when undefined', async () => { // URLパラメータをキャプチャするモック const requestSpy = vi.fn(); diff --git a/test/ranking.test.ts b/test/ranking.test.ts index 8febe23..619f870 100644 --- a/test/ranking.test.ts +++ b/test/ranking.test.ts @@ -29,6 +29,94 @@ describe("RankingBuilder", () => { server.close(); }); + test("fetchOptions をランキング API に渡せる", async () => { + const mockFn = vi.fn(); + + server.use( + http.get("https://api.syosetu.com/rank/rankget/", ({ request }) => { + const url = new URL(request.url); + mockFn(request.headers.get("x-test-header")); + const response: NarouRankingResult[] = Array.from({ length: 1 }, () => ({ + ncode: "N0000AA", + rank: 1, + pt: 1000, + })); + return responseGzipOrJson(response, url); + }) + ); + + const result = await ranking().execute({ + headers: { "x-test-header": "ranking-header" }, + }); + + expect(result).toHaveLength(1); + expect(mockFn).toHaveBeenCalledWith("ranking-header"); + }); + + test("fetchOptions を executeWithFields の両 API に渡せる", async () => { + const rankingHeaderMock = vi.fn(); + const detailHeaderMock = vi.fn(); + + server.use( + http.get("https://api.syosetu.com/rank/rankget/", ({ request }) => { + const url = new URL(request.url); + rankingHeaderMock(request.headers.get("x-test-header")); + const response: NarouRankingResult[] = [ + { ncode: "N0000AA", rank: 1, pt: 1000 }, + ]; + return responseGzipOrJson(response, url); + }), + http.get("https://api.syosetu.com/novelapi/api/", ({ request }) => { + const url = new URL(request.url); + detailHeaderMock(request.headers.get("x-test-header")); + const response = [ + { allcount: 1 }, + { + ncode: "N0000AA", + title: "タイトル", + writer: "作者", + story: "あらすじ", + biggenre: 1, + genre: 101, + keyword: ["キーワード"], + general_firstup: "2021-01-01 00:00:00", + general_lastup: "2021-01-01 00:00:00", + novel_type: 1, + end: 0, + general_all_no: 100, + length: 100, + time: 100, + isstop: 0, + isr15: 0, + isbl: 0, + isgl: 0, + iszankoku: 0, + istensei: 0, + istenni: 0, + global_point: 100, + fav_novel_cnt: 100, + review_cnt: 100, + all_point: 100, + all_hyoka_cnt: 100, + sasie_cnt: 100, + kaiwaritu: 100, + novelupdated_at: "2021-01-01 00:00:00", + updated_at: "2021-01-01 00:00:00", + }, + ]; + return responseGzipOrJson(response, url); + }) + ); + + const result = await ranking().executeWithFields({ + headers: { "x-test-header": "ranking-detail-header" }, + }); + + expect(result).toHaveLength(1); + expect(rankingHeaderMock).toHaveBeenCalledWith("ranking-detail-header"); + expect(detailHeaderMock).toHaveBeenCalledWith("ranking-detail-header"); + }); + describe("type = default", () => { test("date = default", async () => { const mockFn = vi.fn();