From 4546932fedb11f8cd86315d85140078a13acc397 Mon Sep 17 00:00:00 2001 From: deflis Date: Mon, 2 Nov 2026 01:56:13 +0900 Subject: [PATCH] =?UTF-8?q?=E6=A4=9C=E7=B4=A2=E6=9D=A1=E4=BB=B6=E3=81=AE?= =?UTF-8?q?=E5=A4=89=E6=8F=9B=E5=87=A6=E7=90=86=E3=82=92search-condition.t?= =?UTF-8?q?s=E3=81=B8=E9=9B=86=E7=B4=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/search-builder-r18.ts | 9 +-- src/search-builder.ts | 122 +++++----------------------------- src/user-search.ts | 3 +- src/util/search-condition.ts | 94 +++++++++++++++++++------- test/search-condition.test.ts | 5 +- 5 files changed, 97 insertions(+), 136 deletions(-) diff --git a/src/search-builder-r18.ts b/src/search-builder-r18.ts index 8f18562..880c31d 100644 --- a/src/search-builder-r18.ts +++ b/src/search-builder-r18.ts @@ -1,3 +1,4 @@ +import { formatListCondition } from "./util/search-condition.js"; import { NovelSearchBuilderBase } from "./search-builder.js"; import type { ExecuteOptions } from "./narou.js"; import type NarouSearchResults from "./narou-search-results.js"; @@ -44,7 +45,7 @@ export default class SearchBuilderR18< * @return {this} */ r18Site(sites: R18Site | readonly R18Site[]) { - this.set({ nocgenre: NovelSearchBuilderBase.array2string(sites) }); + this.set({ nocgenre: formatListCondition(sites) }); return this; } @@ -54,7 +55,7 @@ export default class SearchBuilderR18< * @return {this} */ xid(ids: number | readonly number[]) { - this.set({ xid: NovelSearchBuilderBase.array2string(ids) }); + this.set({ xid: formatListCondition(ids) }); return this; } @@ -67,7 +68,7 @@ export default class SearchBuilderR18< fields( fields: TFields | readonly TFields[] ): SearchBuilderR18, TOpt> { - this.set({ of: NovelSearchBuilderBase.array2string(fields) }); + this.set({ of: formatListCondition(fields) }); // oxlint-disable-next-line typescript/no-explicit-any return this as any; } @@ -80,7 +81,7 @@ export default class SearchBuilderR18< opt( option: TFields | readonly TFields[] ): SearchBuilderR18> { - this.set({ opt: NovelSearchBuilderBase.array2string(option) }); + this.set({ opt: formatListCondition(option) }); // oxlint-disable-next-line typescript/no-explicit-any return this as any; } diff --git a/src/search-builder.ts b/src/search-builder.ts index b4c5d32..bce1efd 100644 --- a/src/search-builder.ts +++ b/src/search-builder.ts @@ -21,7 +21,6 @@ import type { DateParam, } from "./params.js"; import { BooleanNumber, StopParam } from "./params.js"; -import type { Join } from "./util/type.js"; import type { DateSearchCondition, NumberSearchCondition, @@ -29,7 +28,8 @@ import type { import { formatNumberCondition, formatDateCondition, - formatExactDate, + formatNumberBounds, + formatListCondition, } from "./util/search-condition.js"; export type { @@ -57,34 +57,6 @@ export abstract class SearchBuilderBase< protected api: NarouNovel ) { } - /** - * 配列から重複を除去する - * @protected - * @static - * @param array 配列 - * @returns 重複を除去した配列 - */ - protected static distinct(array: readonly T[]): T[] { - return Array.from(new Set(array)); - } - - /** - * 配列をハイフン区切りの文字列に変換する - * @protected - * @static - * @param n 文字列または数値の配列、あるいは単一の文字列または数値 - * @returns ハイフン区切りの文字列 - */ - protected static array2string( - n: T | readonly T[] - ): Join { - if (Array.isArray(n)) { - return this.distinct(n).join("-") as Join; - } else { - return n.toString() as Join; - } - } - /** * 取得件数を指定する (lim) * @param num 取得件数 (1-500) @@ -332,11 +304,7 @@ export abstract class NovelSearchBuilderBase< * ``` */ length(length: number | readonly number[] | NumberSearchCondition): this { - const value = - typeof length === "number" || Array.isArray(length) - ? NovelSearchBuilderBase.array2string(length) - : formatNumberCondition(length as NumberSearchCondition); - this.set({ length: value }); + this.set({ length: formatNumberCondition(length) }); return this; } @@ -377,17 +345,7 @@ export abstract class NovelSearchBuilderBase< kaiwaritu(condition: NumberSearchCondition): this; kaiwaritu(min: number | NumberSearchCondition, max?: number): this { - if (typeof min === "object") { - this.set({ kaiwaritu: formatNumberCondition(min) }); - return this; - } - let n: number | string; - if (max != null) { - n = `${min}-${max}`; - } else { - n = min; - } - this.set({ kaiwaritu: n }); + this.set({ kaiwaritu: formatNumberBounds(min, max) }); return this; } @@ -414,11 +372,7 @@ export abstract class NovelSearchBuilderBase< * ``` */ sasie(num: number | readonly number[] | NumberSearchCondition): this { - const value = - typeof num === "number" || Array.isArray(num) - ? NovelSearchBuilderBase.array2string(num) - : formatNumberCondition(num as NumberSearchCondition); - this.set({ sasie: value }); + this.set({ sasie: formatNumberCondition(num) }); return this; } @@ -446,11 +400,7 @@ export abstract class NovelSearchBuilderBase< * ``` */ time(num: number | readonly number[] | NumberSearchCondition): this { - const value = - typeof num === "number" || Array.isArray(num) - ? NovelSearchBuilderBase.array2string(num) - : formatNumberCondition(num as NumberSearchCondition); - this.set({ time: value }); + this.set({ time: formatNumberCondition(num) }); return this; } @@ -460,7 +410,7 @@ export abstract class NovelSearchBuilderBase< * @return {this} */ ncode(ncodes: string | readonly string[]): this { - this.set({ ncode: NovelSearchBuilderBase.array2string(ncodes) }); + this.set({ ncode: formatListCondition(ncodes) }); return this; } @@ -481,7 +431,7 @@ export abstract class NovelSearchBuilderBase< * @return {this} */ buntai(buntai: BuntaiParam | readonly BuntaiParam[]): this { - this.set({ buntai: NovelSearchBuilderBase.array2string(buntai) }); + this.set({ buntai: formatListCondition(buntai) }); return this; } @@ -580,26 +530,7 @@ export abstract class NovelSearchBuilderBase< x: string | number | Date | DateSearchCondition, y?: number | Date ): this { - if (typeof x === "object" && !(x instanceof Date)) { - this.set({ lastup: formatDateCondition(x) }); - return this; - } - if (typeof x !== "string" && y === undefined) { - this.set({ lastup: formatExactDate(x) }); - return this; - } - let date: string; - if (typeof x == "string") { - date = x; - } else if (x instanceof Date && y instanceof Date) { - date = `${Math.floor(x.getTime() / 1000)}-${Math.floor( - y.getTime() / 1000 - )}`; - } else { - date = `${String(x)}-${String(y)}`; - } - - this.set({ lastup: date }); + this.set({ lastup: formatDateCondition(x, y) }); return this; } @@ -679,26 +610,7 @@ export abstract class NovelSearchBuilderBase< x: string | number | Date | DateSearchCondition, y?: number | Date ): this { - if (typeof x === "object" && !(x instanceof Date)) { - this.set({ lastupdate: formatDateCondition(x) }); - return this; - } - if (typeof x !== "string" && y === undefined) { - this.set({ lastupdate: formatExactDate(x) }); - return this; - } - let date: string; - if (typeof x == "string") { - date = x; - } else if (x instanceof Date && y instanceof Date) { - date = `${Math.floor(x.getTime() / 1000)}-${Math.floor( - y.getTime() / 1000 - )}`; - } else { - date = `${String(x)}-${String(y)}`; - } - - this.set({ lastupdate: date }); + this.set({ lastupdate: formatDateCondition(x, y) }); return this; } @@ -729,7 +641,7 @@ export default class SearchBuilder< * @return {this} */ bigGenre(genre: BigGenre | readonly BigGenre[]): this { - this.set({ biggenre: SearchBuilder.array2string(genre) }); + this.set({ biggenre: formatListCondition(genre) }); return this; } @@ -740,7 +652,7 @@ export default class SearchBuilder< * @return {this} */ notBigGenre(genre: BigGenre | readonly BigGenre[]): this { - this.set({ notbiggenre: SearchBuilder.array2string(genre) }); + this.set({ notbiggenre: formatListCondition(genre) }); return this; } @@ -751,7 +663,7 @@ export default class SearchBuilder< * @return {this} */ genre(genre: Genre | readonly Genre[]): this { - this.set({ genre: SearchBuilder.array2string(genre) }); + this.set({ genre: formatListCondition(genre) }); return this; } @@ -762,7 +674,7 @@ export default class SearchBuilder< * @return {this} */ notGenre(genre: Genre | readonly Genre[]): this { - this.set({ notgenre: SearchBuilder.array2string(genre) }); + this.set({ notgenre: formatListCondition(genre) }); return this; } @@ -773,7 +685,7 @@ export default class SearchBuilder< * @return {this} */ userId(ids: number | readonly number[]): this { - this.set({ userid: SearchBuilder.array2string(ids) }); + this.set({ userid: formatListCondition(ids) }); return this; } @@ -801,7 +713,7 @@ export default class SearchBuilder< fields( fields: TFields | readonly TFields[] ): SearchBuilder, TOpt> { - this.set({ of: SearchBuilder.array2string(fields) }); + this.set({ of: formatListCondition(fields) }); // oxlint-disable-next-line typescript/no-explicit-any return this as any; } @@ -815,7 +727,7 @@ export default class SearchBuilder< opt( option: TFields | readonly TFields[] ): SearchBuilder> { - this.set({ opt: SearchBuilder.array2string(option) }); + this.set({ opt: formatListCondition(option) }); // oxlint-disable-next-line typescript/no-explicit-any return this as any; } diff --git a/src/user-search.ts b/src/user-search.ts index b162a27..8547fe5 100644 --- a/src/user-search.ts +++ b/src/user-search.ts @@ -1,3 +1,4 @@ +import { formatListCondition } from "./util/search-condition.js"; import NarouSearchResults from "./narou-search-results.js"; import type { UserSearchResult, @@ -96,7 +97,7 @@ export default class UserSearchBuilder< fields( fields: TFields | readonly TFields[] ): UserSearchBuilder> { - this.set({ of: UserSearchBuilder.array2string(fields) }); + this.set({ of: formatListCondition(fields) }); // oxlint-disable-next-line typescript/no-explicit-any return this as any; } diff --git a/src/util/search-condition.ts b/src/util/search-condition.ts index b7b4d73..45f74cc 100644 --- a/src/util/search-condition.ts +++ b/src/util/search-condition.ts @@ -1,3 +1,5 @@ +import type { Join } from "./type.js"; + /** * {@link NovelSearchBuilderBase.lastUpdate}・{@link NovelSearchBuilderBase.lastNovelUpdate} の日時範囲。 * @@ -35,53 +37,95 @@ export type NumberSearchCondition = | { min: number; max?: number } | { min?: number; max: number }; -/** 数値条件をAPIの範囲指定に変換する。 */ -export function formatNumberCondition( - condition: NumberSearchCondition -): `${number | ""}-${number | ""}` { - return formatRange(condition.min, condition.max); +/** 配列から重複を除去する。 */ +function distinct(values: readonly T[]): T[] { + return Array.from(new Set(values)); } -function conditionValue(value: number | Date): number { - const number = - value instanceof Date ? Math.floor(value.getTime() / 1000) : value; - if (!Number.isFinite(number) || !Number.isInteger(number) || number < 0) { - throw new RangeError( - "検索条件には有効な日付または0以上の整数を指定してください" - ); +/** 複数指定を重複のないハイフン区切りの文字列に変換する。 */ +export function formatListCondition( + value: T | readonly T[] +): Join { + return (typeof value === "object" + ? distinct(value).join("-") + : value.toString()) as Join; +} + +/** 数値・配列・オブジェクトの検索条件をAPIの文字列に変換する。 */ +export function formatNumberCondition( + condition: number | readonly number[] | NumberSearchCondition +): Join { + if (typeof condition === "number" || Array.isArray(condition)) { + return formatListCondition(condition); } - return number; + const { min, max } = condition as NumberSearchCondition; + return formatRange(min, max); +} + +/** 数値の1引数・2引数形式とオブジェクト形式を変換する。 */ +export function formatNumberBounds( + min: number | NumberSearchCondition, + max?: number +): number | string { + if (typeof min === "object") return formatNumberCondition(min); + return max == null ? min : `${min}-${max}`; } -/** 日付条件をAPIの範囲指定に変換する。 */ -export function formatDateCondition(condition: DateSearchCondition): string { +/** 日時の文字列・一致・2引数の範囲・オブジェクト形式を変換する。 */ +export function formatDateCondition( + condition: string | number | Date | DateSearchCondition, + end: number | Date | undefined +): string { + if (typeof condition === "string") return condition; + if (typeof condition === "number" || condition instanceof Date) { + if (end === undefined) return formatExactDate(condition); + // 両端がDateの場合は秒に変換する。 + if (condition instanceof Date && end instanceof Date) { + return `${toTimestamp(condition)}-${toTimestamp(end)}`; + } + return `${String(condition)}-${String(end)}`; + } const { from, to } = condition; if (from === undefined && to === undefined) { throw new TypeError("開始日時または終了日時を指定してください"); } return formatRange( - from === undefined ? 0 : from, - to === undefined ? Math.floor(Date.now() / 1000) : to + from === undefined ? 0 : toTimestamp(from), + to === undefined ? Math.floor(Date.now() / 1000) : toTimestamp(to) ); } /** 単一日時を秒単位の一致条件に変換する。 */ -export function formatExactDate(value: number | Date): string { - const timestamp = conditionValue(value); - return `${timestamp}-${timestamp}`; +function formatExactDate(value: number | Date): string { + const timestamp = toTimestamp(value); + return formatRange(timestamp, timestamp); +} + +/** 日時を秒単位に揃える。値の検証は共通の範囲処理で行う。 */ +function toTimestamp(value: number | Date): number { + return value instanceof Date ? Math.floor(value.getTime() / 1000) : value; } +/** 数値の境界を検証し、片側省略を許すAPIの範囲指定を生成する。 */ function formatRange( - from: number | Date | undefined, - to: number | Date | undefined + min: number | undefined, + max: number | undefined ): `${number | ""}-${number | ""}` { - if (from === undefined && to === undefined) { + if (min === undefined && max === undefined) { throw new TypeError("範囲の下限または上限を指定してください"); } - const min = from === undefined ? undefined : conditionValue(from); - const max = to === undefined ? undefined : conditionValue(to); + if (min !== undefined) validateRangeValue(min); + if (max !== undefined) validateRangeValue(max); if (min !== undefined && max !== undefined && min > max) { throw new RangeError("範囲の下限は上限以下にしてください"); } return `${min ?? ""}-${max ?? ""}`; } + +function validateRangeValue(value: number): void { + if (!Number.isFinite(value) || !Number.isInteger(value) || value < 0) { + throw new RangeError( + "検索条件には有効な日付または0以上の整数を指定してください" + ); + } +} diff --git a/test/search-condition.test.ts b/test/search-condition.test.ts index a77a736..80555d7 100644 --- a/test/search-condition.test.ts +++ b/test/search-condition.test.ts @@ -123,7 +123,7 @@ describe.each([search, searchR18])( expect(execute).toHaveBeenCalledWith({ [param]: expected }, undefined); }); - test.each([new Date(NaN), -1, Infinity, 0.5])( + test.each([new Date(NaN), new Date(-1), -1, Infinity, 0.5])( "無効な単一日時 %s を拒否", (value) => { const { builder } = setup(); @@ -135,8 +135,11 @@ describe.each([search, searchR18])( {}, { from: 2, to: 1 }, { from: new Date(NaN) }, + { to: new Date(NaN) }, + { from: new Date(-1) }, { to: Infinity }, { from: -1 }, + { to: 0.5 }, ])("不正な条件 %j を拒否", (condition) => { const { builder, execute } = setup(); expect(() =>