Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 46 additions & 11 deletions src/search-builder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ import type {
DateParam,
} from "./params.js";
import { BooleanNumber, StopParam } from "./params.js";
import type { Join } from "./util/type.js";
import type { Join, RangeParam } from "./util/type.js";

export type DefaultSearchResultFields = keyof Omit<
NarouSearchResult,
Expand Down Expand Up @@ -54,6 +54,26 @@ export abstract class SearchBuilderBase<
return Array.from(new Set(array));
}

/**
* 範囲指定や配列をハイフン区切りの文字列に変換する
* @protected
* @static
* @param n 範囲指定オブジェクト、数値の配列、あるいは単一の数値
* @returns ハイフン区切りの文字列
*/
protected static range2string<T extends number>(
n: T | readonly T[] | RangeParam<T>
): Join<T | ""> {
if (typeof n === "object" && n !== null && !Array.isArray(n)) {
const obj = n as Extract<RangeParam<T>, object>;
if ("equal" in obj) {
return obj.equal.toString() as Join<T>;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Validate equal before coercing it to string

The new object-range path assumes that any object with an equal key has a numeric value, but obj.equal.toString() will throw when JS callers pass { equal: undefined } (or a widened optional value), causing query construction to fail at runtime. This regression is introduced by the new range2string branch and can be avoided by checking for null/undefined (or narrowing to a number) before calling toString and then falling back to the min/max handling or a controlled error.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

equalが数値化を検証するべき

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ご指摘ありがとうございます。equal が数値であるかを検証するため、typeof obj.equal === "number" の実行時チェックを追加しました。

}
return `${obj.min ?? ""}-${obj.max ?? ""}` as Join<T | "">;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

When RangeParam is an empty object {}, this currently returns "-". This might be interpreted as an invalid value by the Narou API. It is safer to return an empty string "" when both min and max are undefined.

      return (obj.min !== undefined || obj.max !== undefined
        ? `${obj.min ?? ""}-${obj.max ?? ""}`
        : "") as Join<T | "">;

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

minかmaxのどちらかがあった時だけ値をセットするように。型でも制限すると尚良い。

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ご指摘ありがとうございます!RangeParam型の定義を修正し、min または max (あるいは equal)が必ず含まれるように制約を加えました。また、range2string内で両方指定されなかった場合に備え、undefined を返すようにしてsetで不要な空文字が設定されないようガードを追加しました。

}
return SearchBuilderBase.array2string(n) as Join<T>;

Copilot AI Apr 12, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

range2string は配列入力をそのまま array2string に委譲しているため、数値配列の要素数が 2 以外(例: [1,2,3])でも 1-2-3 のような文字列になり、range 系パラメータとしては不正なクエリを生成します。range 用ユーティリティとしては、配列はタプル [min, max] のみ受け付ける(型を readonly [T, T] にする)か、ランタイムで長さチェックしてエラーにするなどの防止策を入れた方が安全です。

Suggested change
return SearchBuilderBase.array2string(n) as Join<T>;
if (Array.isArray(n)) {
if (n.length !== 2) {
throw new TypeError(
"range2string only accepts arrays with exactly two elements: [min, max]"
);
}
return `${n[0]}-${n[1]}` as Join<T>;
}
return n.toString() as Join<T>;

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

array2stringが意図しているタプルを返すかを(型かランタイムで)検証をしっかりすること

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

range2string は配列入力をそのまま array2string に委譲しているため、数値配列の要素数が 2 以外(例: [1,2,3])でも 1-2-3 のような文字列になり、range 系パラメータとしては不正なクエリを生成します。range 用ユーティリティとしては、配列はタプル [min, max] のみ受け付ける(型を readonly [T, T] にする)か、ランタイムで長さチェックしてエラーにするなどの防止策を入れた方が安全です。

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ご指摘ありがとうございます!range2stringが不適切な要素数(3以上)の配列を受け付けないようにランタイムチェックを追加し、さらに安全性を高めるため、TypeScriptの型定義でも readonly [number, number] のタプル型を要求するように変更しました。

}

/**
* 配列をハイフン区切りの文字列に変換する
* @protected
Expand Down Expand Up @@ -294,11 +314,12 @@ export abstract class NovelSearchBuilderBase<
/**
* 抽出する作品の文字数を指定します (length)。
* 範囲指定する場合は、最小文字数と最大文字数をハイフン(-)記号で区切ってください。
* @param length 文字数、または[最小文字数, 最大文字数]
* オブジェクトによる指定 ({ min?: number, max?: number } または { equal: number }) も可能です。
* @param length 文字数、または[最小文字数, 最大文字数]、またはオブジェクト指定
* @return {this}
*/
length(length: number | readonly number[]): this {
this.set({ length: NovelSearchBuilderBase.array2string(length) });
length(length: number | readonly number[] | RangeParam<number>): this {
this.set({ length: NovelSearchBuilderBase.range2string(length) });
return this;
}

Expand All @@ -315,8 +336,20 @@ export abstract class NovelSearchBuilderBase<
* @return {this}
*/
kaiwaritu(min: number, max: number): this;
/**
* 抽出する作品の会話率を%単位で範囲指定またはオブジェクトで指定します (kaiwaritu)。
* @param range 範囲指定オブジェクト
* @return {this}
*/
kaiwaritu(range: RangeParam<number>): this;

kaiwaritu(minOrRange: number | RangeParam<number>, max?: number): this {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

For consistency with length, sasie, and time, the kaiwaritu method should also support readonly number[] in its signature. The implementation already correctly handles arrays via range2string because typeof array === "object" is true.

  kaiwaritu(
    minOrRange: number | readonly number[] | RangeParam<number>,
    max?: number
  ): this {

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

会話率も同じシグネチャのほうがいい

if (typeof minOrRange === "object" && minOrRange !== null) {
this.set({ kaiwaritu: NovelSearchBuilderBase.range2string(minOrRange) });
return this;
Comment on lines +357 to +363

Copilot AI Apr 14, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

kaiwaritu のオブジェクト分岐が typeof minOrRange === "object" だけなので、配列もここに入ってしまいます(例: JS利用者が誤って kaiwaritu([10, 20]) を渡す)。結果として range2string が配列を受け付けて文字列化し、意図しない入力をサイレントに受理する/空配列で "" を生成する可能性があります。RangeParam として扱う条件を !Array.isArray(minOrRange) まで含めるか、"min" in ... || "max" in ... || "equal" in ... のようにキーで判定して配列を除外してください。

Copilot uses AI. Check for mistakes.
}

kaiwaritu(min: number, max?: number): this {
const min = minOrRange;
let n: number | string;
if (max != null) {
n = `${min}-${max}`;
Expand All @@ -329,21 +362,23 @@ export abstract class NovelSearchBuilderBase<

/**
* 抽出する作品の挿絵数を指定します (sasie)。
* @param num 挿絵数、または[最小挿絵数, 最大挿絵数]
* オブジェクトによる指定 ({ min?: number, max?: number } または { equal: number }) も可能です。
* @param num 挿絵数、または[最小挿絵数, 最大挿絵数]、またはオブジェクト指定
* @return {this}
*/
sasie(num: number | readonly number[]): this {
this.set({ sasie: NovelSearchBuilderBase.array2string(num) });
sasie(num: number | readonly number[] | RangeParam<number>): this {
this.set({ sasie: NovelSearchBuilderBase.range2string(num) });
return this;
}

/**
* 抽出する作品の予想読了時間を分単位で指定します (time)。
* @param num 読了時間(分)、または[最小読了時間, 最大読了時間]
* オブジェクトによる指定 ({ min?: number, max?: number } または { equal: number }) も可能です。
* @param num 読了時間(分)、または[最小読了時間, 最大読了時間]、またはオブジェクト指定
* @return {this}
*/
time(num: number | readonly number[]): this {
this.set({ time: NovelSearchBuilderBase.array2string(num) });
time(num: number | readonly number[] | RangeParam<number>): this {
this.set({ time: NovelSearchBuilderBase.range2string(num) });
return this;
Comment on lines 377 to 402

Copilot AI Apr 12, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

sasie / timeRangeParam を受け付けるようになりましたが、テストが一部の形(例: sasie{ min } のみ、time{ min, max } のみ)しかカバーしていません。{ max }{ equal } など、サポートするとして公開したオブジェクト形状を最低限網羅するテストを追加してください。

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

テストが足りてない

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ご指摘ありがとうございます。sasie および time に対しても { min: x, max: y }{ equal: z } といったオブジェクト形式での網羅的なテストケースを追加し、不足を補いました。

}

Expand Down
6 changes: 6 additions & 0 deletions src/util/type.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,3 +24,9 @@ type Stringable = string | number | bigint | boolean | null | undefined;
* type JoinedNumbers = Join<Numbers>; // '1' | '2' | '3' | '1-1' | '1-2' | '1-3' | '2-1' | '2-2' | '2-3' | '3-1' | '3-2' | '3-3'
*/
export type Join<T extends Stringable> = `${T}-${T}` | `${T}`;

/**
* 範囲指定のためのオブジェクトの型。
* @template T - 範囲指定する値の型
*/
export type RangeParam<T extends number> = { min?: T; max?: T } | { equal: T };
80 changes: 80 additions & 0 deletions test/search-builder.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -787,6 +787,46 @@ describe("SearchBuilder", () => {
expect(mockFn).toHaveBeenCalledTimes(1);
expect(mockFn).toHaveBeenCalledWith(`0-${length}`, "5", "json", 3);
});

test("if length = { min: 1000 }", async () => {
const mockFn = vi.fn();
setupMockHandler(mockFn, ["length", "gzip", "out"]);

const result = await NarouAPI.search().length({ min: 1000 }).execute();
expect(result.allcount).toBe(1);
expect(mockFn).toHaveBeenCalledTimes(1);
expect(mockFn).toHaveBeenCalledWith(`1000-`, "5", "json", 3);
});

test("if length = { max: 1000 }", async () => {
const mockFn = vi.fn();
setupMockHandler(mockFn, ["length", "gzip", "out"]);

const result = await NarouAPI.search().length({ max: 1000 }).execute();
expect(result.allcount).toBe(1);
expect(mockFn).toHaveBeenCalledTimes(1);
expect(mockFn).toHaveBeenCalledWith(`-1000`, "5", "json", 3);
});

test("if length = { min: 100, max: 1000 }", async () => {
const mockFn = vi.fn();
setupMockHandler(mockFn, ["length", "gzip", "out"]);

const result = await NarouAPI.search().length({ min: 100, max: 1000 }).execute();
expect(result.allcount).toBe(1);
expect(mockFn).toHaveBeenCalledTimes(1);
expect(mockFn).toHaveBeenCalledWith(`100-1000`, "5", "json", 3);
});

test("if length = { equal: 1000 }", async () => {
const mockFn = vi.fn();
setupMockHandler(mockFn, ["length", "gzip", "out"]);

const result = await NarouAPI.search().length({ equal: 1000 }).execute();
expect(result.allcount).toBe(1);
expect(mockFn).toHaveBeenCalledTimes(1);
expect(mockFn).toHaveBeenCalledWith(`1000`, "5", "json", 3);
});
});

describe("kaiwaritu", () => {
Expand Down Expand Up @@ -823,6 +863,26 @@ describe("SearchBuilder", () => {
expect(mockFn).toHaveBeenCalledTimes(1);
expect(mockFn).toHaveBeenCalledWith(`${min}-${max}`, "5", "json", 3);
});

test("if kaiwaritu = { min: 10 }", async () => {
const mockFn = vi.fn();
setupMockHandler(mockFn, ["kaiwaritu", "gzip", "out"]);

const result = await NarouAPI.search().kaiwaritu({ min: 10 }).execute();
expect(result.allcount).toBe(1);
expect(mockFn).toHaveBeenCalledTimes(1);
expect(mockFn).toHaveBeenCalledWith(`10-`, "5", "json", 3);
});

test("if kaiwaritu = { max: 50 }", async () => {
const mockFn = vi.fn();
setupMockHandler(mockFn, ["kaiwaritu", "gzip", "out"]);

const result = await NarouAPI.search().kaiwaritu({ max: 50 }).execute();
expect(result.allcount).toBe(1);
expect(mockFn).toHaveBeenCalledTimes(1);
expect(mockFn).toHaveBeenCalledWith(`-50`, "5", "json", 3);
});

Copilot AI Apr 14, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

kaiwaritu の RangeParam 対応のテストが { min }{ max } のみで、公開している形({ min, max }{ equal })が未カバーです。range2string の分岐(ハイフン区切り/単一値)を最低限網羅できるよう、kaiwaritu({ min: 10, max: 50 })kaiwaritu({ equal: 10 }) のケースも追加してください。

Suggested change
});
});
test("if kaiwaritu = { min: 10, max: 50 }", async () => {
const mockFn = vi.fn();
setupMockHandler(mockFn, ["kaiwaritu", "gzip", "out"]);
const result = await NarouAPI.search()
.kaiwaritu({ min: 10, max: 50 })
.execute();
expect(result.allcount).toBe(1);
expect(mockFn).toHaveBeenCalledTimes(1);
expect(mockFn).toHaveBeenCalledWith(`10-50`, "5", "json", 3);
});
test("if kaiwaritu = { equal: 10 }", async () => {
const mockFn = vi.fn();
setupMockHandler(mockFn, ["kaiwaritu", "gzip", "out"]);
const result = await NarouAPI.search().kaiwaritu({ equal: 10 }).execute();
expect(result.allcount).toBe(1);
expect(mockFn).toHaveBeenCalledTimes(1);
expect(mockFn).toHaveBeenCalledWith(`10`, "5", "json", 3);
});

Copilot uses AI. Check for mistakes.
});

describe("sasie", () => {
Expand Down Expand Up @@ -859,6 +919,16 @@ describe("SearchBuilder", () => {
expect(mockFn).toHaveBeenCalledTimes(1);
expect(mockFn).toHaveBeenCalledWith(`${min}-${max}`, "5", "json", 3);
});

test("if sasie = { min: 5 }", async () => {
const mockFn = vi.fn();
setupMockHandler(mockFn, ["sasie", "gzip", "out"]);

const result = await NarouAPI.search().sasie({ min: 5 }).execute();
expect(result.allcount).toBe(1);
expect(mockFn).toHaveBeenCalledTimes(1);
expect(mockFn).toHaveBeenCalledWith(`5-`, "5", "json", 3);
});
});

describe("time", () => {
Expand Down Expand Up @@ -895,6 +965,16 @@ describe("SearchBuilder", () => {
expect(mockFn).toHaveBeenCalledTimes(1);
expect(mockFn).toHaveBeenCalledWith(`${min}-${max}`, "5", "json", 3);
});

test("if time = { min: 10, max: 20 }", async () => {
const mockFn = vi.fn();
setupMockHandler(mockFn, ["time", "gzip", "out"]);

const result = await NarouAPI.search().time({ min: 10, max: 20 }).execute();
expect(result.allcount).toBe(1);
expect(mockFn).toHaveBeenCalledTimes(1);
expect(mockFn).toHaveBeenCalledWith(`10-20`, "5", "json", 3);
});
});

describe("ncode", () => {
Expand Down
Loading