From 17469ad6e67edeb1f6f92ddaded96e4b3751752f Mon Sep 17 00:00:00 2001 From: lizhixuan Date: Fri, 24 Jul 2026 21:57:47 +0800 Subject: [PATCH] feat(provider-nailbook): keyless Japanese nail-art image provider MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add @refkit/provider-nailbook — image references from Nailbook (nailbook.jp). Calls the site's own JSON endpoint (POST /api/web/photo/search) rather than scraping the client-rendered list HTML, whose embedded bootstrap carries photo IDs but no image URLs. One request per search, no fan-out. Discovery-class rights: no per-item license metadata, so every result is license: 'unknown' + rehostPolicy: 'thumbnail-only' (gates to needs-review; mirrors the Brave discovery moat). Japanese tag words recall best. Wired into @refkit/mcp's zero-config keyless default set (hard dependency), README, and the keyless-set test; registered in the root vitest projects list. Verified: typecheck, lint, full vitest (369 passed), package build, and a live smoke against the real API (non-empty results, canonicalUrl = /design//, thumbnail HEAD 200 image/*). --- .changeset/provider-nailbook.md | 18 ++ packages/mcp/README.md | 2 +- packages/mcp/package.json | 1 + packages/mcp/src/__tests__/mcp.test.ts | 2 +- packages/mcp/src/cli.ts | 3 +- packages/provider-nailbook/LICENSE | 201 ++++++++++++++++++ packages/provider-nailbook/README.md | 53 +++++ packages/provider-nailbook/package.json | 58 +++++ .../src/__tests__/fixtures/photo-search.json | 175 +++++++++++++++ .../src/__tests__/live.test.ts | 20 ++ .../src/__tests__/nailbook.test.ts | 99 +++++++++ packages/provider-nailbook/src/index.ts | 137 ++++++++++++ packages/provider-nailbook/tsconfig.json | 5 + packages/provider-nailbook/tsup.config.ts | 10 + packages/provider-nailbook/vitest.config.ts | 2 + pnpm-lock.yaml | 13 ++ vitest.config.ts | 1 + 17 files changed, 797 insertions(+), 3 deletions(-) create mode 100644 .changeset/provider-nailbook.md create mode 100644 packages/provider-nailbook/LICENSE create mode 100644 packages/provider-nailbook/README.md create mode 100644 packages/provider-nailbook/package.json create mode 100644 packages/provider-nailbook/src/__tests__/fixtures/photo-search.json create mode 100644 packages/provider-nailbook/src/__tests__/live.test.ts create mode 100644 packages/provider-nailbook/src/__tests__/nailbook.test.ts create mode 100644 packages/provider-nailbook/src/index.ts create mode 100644 packages/provider-nailbook/tsconfig.json create mode 100644 packages/provider-nailbook/tsup.config.ts create mode 100644 packages/provider-nailbook/vitest.config.ts diff --git a/.changeset/provider-nailbook.md b/.changeset/provider-nailbook.md new file mode 100644 index 0000000..4378ce6 --- /dev/null +++ b/.changeset/provider-nailbook.md @@ -0,0 +1,18 @@ +--- +"@refkit/provider-nailbook": minor +"@refkit/mcp": minor +--- + +New keyless provider `@refkit/provider-nailbook` — image references from Nailbook +(nailbook.jp), a large Japanese nail-design catalog. Recall is best with Japanese tag +words (マグネット, ニュアンス, ちゅるん…). Results are discovery-class: no per-item +license metadata, so each carries `license: 'unknown'` + `rehostPolicy: 'thumbnail-only'` +and gates to `needs-review` (never auto-allowed) — surface the CDN thumbnail only, never +rehost the original. + +Rather than scraping the client-rendered `/design/` list HTML (whose embedded bootstrap +carries photo IDs but no image URLs), the provider calls the same JSON endpoint the site's +own frontend uses (`POST /api/web/photo/search`), returning full photo objects in one +request. Each `search()` makes exactly one request with no multi-page fan-out. + +`@refkit/mcp` boots Nailbook in its zero-config keyless default set. diff --git a/packages/mcp/README.md b/packages/mcp/README.md index 88d1f1b..0edbd22 100644 --- a/packages/mcp/README.md +++ b/packages/mcp/README.md @@ -10,7 +10,7 @@ Point any MCP client at: npx -y @refkit/mcp ``` -It boots with the keyless sources (Met, Art Institute of Chicago, Wikimedia Commons, Openverse + audio, Project Gutenberg, PoetryDB) and auto-adds any BYOK source whose key is in the environment. Each key is read as a unified `REFKIT__KEY` name first, falling back to the provider's legacy name (both are honored indefinitely): +It boots with the keyless sources (Met, Art Institute of Chicago, Wikimedia Commons, Openverse + audio, Project Gutenberg, PoetryDB, Nailbook — Japanese nail-art, discovery-class `license: 'unknown'`, best queried with Japanese tag words) and auto-adds any BYOK source whose key is in the environment. Each key is read as a unified `REFKIT__KEY` name first, falling back to the provider's legacy name (both are honored indefinitely): ```bash REFKIT_UNSPLASH_KEY=… REFKIT_PEXELS_KEY=… REFKIT_PIXABAY_KEY=… REFKIT_FLICKR_KEY=… REFKIT_SMITHSONIAN_KEY=… REFKIT_BRAVE_KEY=… npx -y @refkit/mcp diff --git a/packages/mcp/package.json b/packages/mcp/package.json index ec3490c..a17cb82 100644 --- a/packages/mcp/package.json +++ b/packages/mcp/package.json @@ -41,6 +41,7 @@ "@refkit/provider-gutendex": "workspace:*", "@refkit/provider-internet-archive": "workspace:*", "@refkit/provider-met": "workspace:*", + "@refkit/provider-nailbook": "workspace:*", "@refkit/provider-openverse": "workspace:*", "@refkit/provider-poetrydb": "workspace:*", "@refkit/provider-polyhaven": "workspace:*", diff --git a/packages/mcp/src/__tests__/mcp.test.ts b/packages/mcp/src/__tests__/mcp.test.ts index f4e494e..f4a847a 100644 --- a/packages/mcp/src/__tests__/mcp.test.ts +++ b/packages/mcp/src/__tests__/mcp.test.ts @@ -459,7 +459,7 @@ describe('maxCursorSeenFromEnv (cursor size knob for size-clamped tool outputs)' describe('defaultProviders (zero-config CLI wiring)', () => { it('includes every keyless provider by default', async () => { const ids = (await defaultProviders({})).map(p => p.id) - for (const id of ['openverse', 'wikimedia-commons', 'met', 'artic', 'gutendex', 'poetrydb', 'rijksmuseum', 'polyhaven', 'ambientcg', 'internet-archive']) { + for (const id of ['openverse', 'wikimedia-commons', 'met', 'artic', 'gutendex', 'poetrydb', 'rijksmuseum', 'polyhaven', 'ambientcg', 'internet-archive', 'nailbook']) { expect(ids).toContain(id) } }) diff --git a/packages/mcp/src/cli.ts b/packages/mcp/src/cli.ts index 0e434b2..288e2ce 100644 --- a/packages/mcp/src/cli.ts +++ b/packages/mcp/src/cli.ts @@ -13,6 +13,7 @@ import { poetrydb } from '@refkit/provider-poetrydb' import { rijksmuseum } from '@refkit/provider-rijksmuseum' import { polyhaven, ambientcg } from '@refkit/provider-polyhaven' import { internetArchive } from '@refkit/provider-internet-archive' +import { nailbook } from '@refkit/provider-nailbook' import { serveStdio } from './index' /** One BYOK source: its unified + legacy env var names, and a lazy loader for its @@ -92,7 +93,7 @@ export const BYOK_SOURCES: ByokSource[] = [ export async function defaultProviders(env: NodeJS.ProcessEnv = process.env): Promise { const providers: ReferenceProvider[] = [ openverse(), openverseAudio(), wikimediaCommons(), met(), artic(), gutendex(), poetrydb(), - rijksmuseum(), polyhaven(), ambientcg(), internetArchive(), + rijksmuseum(), polyhaven(), ambientcg(), internetArchive(), nailbook(), ] // Independent module loads — run them concurrently (startup cost = max, not sum) // and keep BYOK_SOURCES order in the provider list. diff --git a/packages/provider-nailbook/LICENSE b/packages/provider-nailbook/LICENSE new file mode 100644 index 0000000..c1c4eb0 --- /dev/null +++ b/packages/provider-nailbook/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or Derivative + Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, excluding + those notices that do not pertain to any part of the Derivative + Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2026 refkit authors + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/packages/provider-nailbook/README.md b/packages/provider-nailbook/README.md new file mode 100644 index 0000000..238b823 --- /dev/null +++ b/packages/provider-nailbook/README.md @@ -0,0 +1,53 @@ +# @refkit/provider-nailbook + +Search **Nailbook** ([nailbook.jp](https://nailbook.jp)) — a large Japanese nail-design catalog — as image references, a provider satellite for **refkit** (use with [`@refkit/core`](https://www.npmjs.com/package/@refkit/core)). + +- **Source:** Nailbook ([nailbook.jp](https://nailbook.jp)) +- **Auth:** keyless +- **Modality:** image +- **License:** `unknown` (discovery-class — see below) + +## Usage + +```ts +import { createRefkit } from '@refkit/core' +import { nailbook } from '@refkit/provider-nailbook' + +const refkit = createRefkit({ providers: [nailbook()] }) +const refs = await refkit.search({ query: 'マグネット', modalities: ['image'] }) +``` + +## Query language + +Recall is best with **Japanese** tag words — `マグネット` (magnetic), `ニュアンス` (nuance), +`ちゅるん` (glossy/jelly), `フレンチ` (french), etc. English or Chinese queries recall +poorly; translating the user's intent into Japanese tag vocabulary is the caller's +(host skill's) job. + +## Rights: discovery-class, not license-cleared + +Nailbook photos are user-posted nail-art shots with **no per-item license metadata**, so +every result carries `license: 'unknown'` and `rehostPolicy: 'thumbnail-only'`. refkit's +use-gate therefore returns `needs-review` for all of them (never auto-allowed). Treat them +as visual **inspiration / moodboard** references — verify the source before any commercial +or generation use. Pass `gateFor: 'commercial-product'` to drop them automatically. Only +the resized CDN thumbnail is safe to surface; never rehost the original image. + +## Crawler politeness + +Each `search()` makes exactly **one** request and does **no** multi-page fan-out — this is +interactive, on-demand retrieval, not batch crawling. nailbook.jp's `robots.txt` is open to +general agents (it blocks AI-training crawlers specifically) with a `Crawl-delay: 10` that +applies to crawlers; a single request per user query is well within that spirit. + +## Implementation note + +nailbook.jp is a client-rendered SPA: the `/design/` list HTML embeds only a thin bootstrap +(photo IDs, no image URLs). This provider instead calls the same JSON endpoint the site's own +frontend uses (`POST /api/web/photo/search`), which returns full photo objects — image CDN +`base_url`, dimensions, caption, tags, author — in a single request. Pagination there is a +stateful `scrolling_key` cursor (the `page` parameter is a no-op), so this provider surfaces +one page per query and declares no `page` control. + +See [`@refkit/core`](https://www.npmjs.com/package/@refkit/core) for the full API, and gate by +intended use with `refkit.evaluateUse(ref, 'commercial-product')`. diff --git a/packages/provider-nailbook/package.json b/packages/provider-nailbook/package.json new file mode 100644 index 0000000..35e04a1 --- /dev/null +++ b/packages/provider-nailbook/package.json @@ -0,0 +1,58 @@ +{ + "name": "@refkit/provider-nailbook", + "version": "0.0.0", + "description": "Nailbook (nailbook.jp) nail-design provider satellite for refkit — keyless Japanese nail-art image references.", + "type": "module", + "license": "Apache-2.0", + "repository": { + "type": "git", + "url": "git+https://github.com/refkitjs/refkit.git", + "directory": "packages/provider-nailbook" + }, + "homepage": "https://github.com/refkitjs/refkit/tree/main/packages/provider-nailbook#readme", + "bugs": "https://github.com/refkitjs/refkit/issues", + "keywords": [ + "refkit", + "reference-retrieval", + "license", + "attribution", + "refkit-provider", + "nailbook", + "nail-art", + "nail-design", + "japanese", + "images" + ], + "main": "./src/index.ts", + "types": "./src/index.ts", + "exports": { + ".": "./src/index.ts" + }, + "scripts": { + "typecheck": "tsc --noEmit", + "test": "vitest run", + "test:watch": "vitest watch", + "build": "tsup", + "prepublishOnly": "tsup" + }, + "dependencies": { + "@refkit/core": "workspace:*" + }, + "files": [ + "dist", + "LICENSE" + ], + "publishConfig": { + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + } + }, + "devDependencies": { + "@refkit/provider-testkit": "workspace:*" + } +} diff --git a/packages/provider-nailbook/src/__tests__/fixtures/photo-search.json b/packages/provider-nailbook/src/__tests__/fixtures/photo-search.json new file mode 100644 index 0000000..9fdf212 --- /dev/null +++ b/packages/provider-nailbook/src/__tests__/fixtures/photo-search.json @@ -0,0 +1,175 @@ +{ + "data": { + "total_count": 796461, + "scrolling_key": "eyJoYXNoIjogIjFmMWJkOThkY2IxNTYyY2I5ZDAzZDc2YTRmZTU4NThkIiwgInNlYXJjaF9hZnRlciI6IFsxMDA0OTU1OF0sICJvcmRlcl9jb250ZXh0IjogbnVsbH0=", + "items": [ + { + "id": 10049726, + "memo": "手が綺麗に見えるマグネットネイル\n\n\n\n#マグネットネイル #マグネット #グレージュ #手が綺麗に見えるネイル #シンプルネイル #オフィスネイル #豊橋ネイル #豊川ネイル #田原ネイル", + "price": 5000, + "kawaii_count": 0, + "create_datetime": "2026-07-24 22:30:17", + "images": [ + { + "width": 2727, + "height": 2727, + "base_url": "https://cnv.nailbook.jp/photo/35052322/", + "video": null, + "video_url": null + } + ], + "user": { + "id": 1890503, + "display_name": "china" + }, + "tags": [ + { + "id": 135, + "name": "オールシーズン" + }, + { + "id": 84, + "name": "オフィス" + }, + { + "id": 111, + "name": "ハンド" + } + ] + }, + { + "id": 10049724, + "memo": "お客様ネイル💅\n\n#チークネイル\n#マグネットネイル\n#一宮市ネイルサロン", + "price": 5000, + "kawaii_count": 0, + "create_datetime": "2026-07-24 22:29:16", + "images": [ + { + "width": 1107, + "height": 1477, + "base_url": "https://cnv.nailbook.jp/photo/35052312/", + "video": null, + "video_url": null + }, + { + "width": 1108, + "height": 1478, + "base_url": "https://cnv.nailbook.jp/photo/35052313/", + "video": null, + "video_url": null + } + ], + "user": { + "id": 1624140, + "display_name": "NailSalon chameleon" + }, + "tags": [ + { + "id": 135, + "name": "オールシーズン" + }, + { + "id": 111, + "name": "ハンド" + } + ] + }, + { + "id": 10049664, + "memo": "夏の全てを楽しむためのリゾートネイル💅\n太陽の下ではクリア感が増し増しでピンクが可愛さを演出。\nサンセットやクラブでは光を強く反射して\nカッコよく、強さを演出してくれます🦚\n\nプルメリア風の花もクリアでつくったので、指先に重さを感じさせない。むしろ光をより取り込んで輝きます🫶\n\n#kokoist item\nBOBA Gel Low\n\n#gelip ジェリップで長さ出しすると更に輝き増しちゃいますね🫣\n\n-—private nail salon————\n骨色ネイル ネイリストSHUN\n池袋で一緒におしゃべりしながら指先のオシャレ、オタ活のお手伝い致します!\n一緒にネイル作りましょう!\nネイルサロン初心者の方もショートネイルさんもロングネイルさんも大歓迎です!\n合わせて、ホワイトニングも行なっております。爪も歯も自信持っていきましょ。\n脱毛、フェイシャルメニュー別室にてあり\n\nご予約はLINE受付からどうぞご連絡下さい!お気軽に質問等も合わせてご連絡お待ちしてます!\n現在は現金・PayPayの支払いのみの対応となります。よろしくお願いいたします!\n\nプライベートサロン場所【東京都豊島区東池袋2-60-2 池袋パークハイツ 1105】\n池袋駅東口から徒歩8分内です!\n\nネイリスト様に向けたKOKOIST公式セミナーやジェリップ認定サロンになるためのセミナーも随時開催受付しております。\n———————————", + "price": 9800, + "kawaii_count": 0, + "create_datetime": "2026-07-24 22:07:54", + "images": [ + { + "width": 1266, + "height": 1266, + "base_url": "https://cnv.nailbook.jp/photo/35052000/", + "video": null, + "video_url": null + }, + { + "width": 3024, + "height": 3024, + "base_url": "https://cnv.nailbook.jp/photo/35052001/", + "video": null, + "video_url": null + }, + { + "width": 3024, + "height": 3024, + "base_url": "https://cnv.nailbook.jp/photo/35052002/", + "video": null, + "video_url": null + }, + { + "width": 720, + "height": 1280, + "base_url": "https://cnv.nailbook.jp/photo/35052003/", + "video": { + "width": 720, + "height": 1280, + "duration": 9.837, + "url": "https://cnv.nailbook.jp/video/photo/35052003" + }, + "video_url": "https://cnv.nailbook.jp/video/photo/35052003" + } + ], + "user": { + "id": 1917351, + "display_name": "骨色nail SHUN【池袋個室】メンズネイリスト" + }, + "tags": [ + { + "id": 135, + "name": "オールシーズン" + }, + { + "id": 93, + "name": "リゾート" + }, + { + "id": 86, + "name": "パーティー" + } + ] + }, + { + "id": 10049721, + "memo": "", + "price": null, + "kawaii_count": 0, + "create_datetime": "2026-07-24 22:28:23", + "images": [ + { + "width": 1574, + "height": 1740, + "base_url": "https://cnv.nailbook.jp/photo/35052299/", + "video": null, + "video_url": null + } + ], + "user": { + "id": 1226845, + "display_name": "komorebi nailsalon" + }, + "tags": [ + { + "id": 190, + "name": "マグネット" + }, + { + "id": 15, + "name": "ブラウン" + }, + { + "id": 7, + "name": "ゴールド" + } + ] + } + ] + }, + "result": true, + "status_code": 200 +} \ No newline at end of file diff --git a/packages/provider-nailbook/src/__tests__/live.test.ts b/packages/provider-nailbook/src/__tests__/live.test.ts new file mode 100644 index 0000000..0de7d34 --- /dev/null +++ b/packages/provider-nailbook/src/__tests__/live.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from 'vitest' +import { searchConformant } from '@refkit/provider-testkit' +import { nailbook } from '../index' + +const live = process.env.REFKIT_LIVE === '1' + +// Keyless — runs with REFKIT_LIVE=1. One real search + one thumbnail HEAD, no +// multi-page fan-out. Japanese tag words recall best (マグネット = magnetic). +describe.skipIf(!live)('live smoke: nailbook', () => { + it('returns conformant, HEAD-able references from the real API', { timeout: 30_000 }, async () => { + const refs = await searchConformant(nailbook(), globalThis.fetch, { query: 'マグネット' }) + expect(refs.length).toBeGreaterThan(0) + const r = refs[0] + expect(r.canonicalUrl).toMatch(/^https:\/\/nailbook\.jp\/design\/\d+\/$/) + expect(r.thumbnail?.url).toBeDefined() + const head = await fetch(r.thumbnail!.url, { method: 'HEAD' }) + expect(head.status).toBe(200) + expect(head.headers.get('content-type') ?? '').toMatch(/^image\//) + }) +}) diff --git a/packages/provider-nailbook/src/__tests__/nailbook.test.ts b/packages/provider-nailbook/src/__tests__/nailbook.test.ts new file mode 100644 index 0000000..e7047bc --- /dev/null +++ b/packages/provider-nailbook/src/__tests__/nailbook.test.ts @@ -0,0 +1,99 @@ +import { describe, expect, it } from 'vitest' +import { searchConformant } from '@refkit/provider-testkit' +import { evaluateUse, type ProviderContext } from '@refkit/core' +import { nailbook } from '../index' +import fixture from './fixtures/photo-search.json' + +// A real (trimmed) `/api/web/photo/search` response captured live for keyword +// "マグネット": item 0 single-image, item 1 multi-image, item 2 carries a video on a +// later image, item 3 has an empty caption (exercises the tag-name title fallback). +type Json = Record + +/** Stub fetch that records the outgoing request and replies with `payload`. */ +function recordingCtx(payload: unknown): { ctx: ProviderContext; seen: { url?: string; init?: RequestInit } } { + const seen: { url?: string; init?: RequestInit } = {} + const ctx: ProviderContext = { + fetch: (async (input: Parameters[0], init?: RequestInit) => { + seen.url = String(input) + seen.init = init + return new Response(JSON.stringify(payload), { status: 200, headers: { 'content-type': 'application/json' } }) + }) as typeof fetch, + } + return { ctx, seen } +} + +const okCtx = () => recordingCtx(fixture).ctx + +describe('nailbook provider', () => { + it('passes provider conformance on real API data', async () => { + const refs = await searchConformant(nailbook(), okCtx().fetch, { query: 'マグネット' }) + expect(refs).toHaveLength(4) + expect(refs.every(r => r.id.startsWith('nailbook:'))).toBe(true) + expect(refs.every(r => r.modality === 'image')).toBe(true) + }) + + it('POSTs the keyword as JSON to the photo-search endpoint', async () => { + const { ctx, seen } = recordingCtx(fixture) + await nailbook().search({ text: 'マグネット', modalities: ['image'] }, ctx) + expect(seen.url).toBe('https://nailbook.jp/api/web/photo/search') + expect(seen.init?.method).toBe('POST') + const headers = seen.init?.headers as Record + expect(headers['Content-Type']).toBe('application/json') // load-bearing: else HTTP 400 + expect(headers['X-Requested-With']).toBe('XMLHttpRequest') + expect(JSON.parse(String(seen.init?.body))).toEqual({ keyword: 'マグネット' }) + }) + + it('maps a photo to a discovery-class image reference', async () => { + const refs = await nailbook().search({ text: 'マグネット', modalities: ['image'] }, okCtx()) + const r = refs[0] + expect(r.canonicalUrl).toBe('https://nailbook.jp/design/10049726/') + expect(r.source).toEqual({ providerId: 'nailbook', sourceUrl: 'https://nailbook.jp/design/10049726/' }) + expect(r.thumbnail?.url).toBe('https://cnv.nailbook.jp/photo/35052322/320_lc') + expect(r.preview).toEqual({ url: 'https://cnv.nailbook.jp/photo/35052322/1280_lc', mediaType: 'image/jpeg' }) + expect(r.visual).toEqual({ width: 2727, height: 2727 }) + expect(r.title).toContain('マグネット') + expect(r.rights.license).toBe('unknown') + expect(r.rights.rehostPolicy).toBe('thumbnail-only') + expect(r.rights.author).toBe('china') + expect(r.rights.raw).toEqual({ sourceTerms: '', sourceUrl: 'https://nailbook.jp/design/10049726/' }) + }) + + it('unknown license → needs-review under any commercial intent', async () => { + const refs = await nailbook().search({ text: 'x', modalities: ['image'] }, okCtx()) + expect(evaluateUse(refs[0].rights, 'commercial-product').decision).toBe('needs-review') + }) + + it('uses the primary image for multi-image and video-bearing photos', async () => { + const refs = await nailbook().search({ text: 'x', modalities: ['image'] }, okCtx()) + // item 1 has two images — the first one wins + expect(refs[1].thumbnail?.url).toBe('https://cnv.nailbook.jp/photo/35052312/320_lc') + // item 2 has a video on a later image, but images[0] is a still — thumbnail must be + // an image variant, never the /video/ URL + expect(refs[2].thumbnail?.url).toBe('https://cnv.nailbook.jp/photo/35052000/320_lc') + expect(refs[2].thumbnail?.url).not.toContain('/video/') + }) + + it('falls back to tag labels when the caption is empty', async () => { + const refs = await nailbook().search({ text: 'x', modalities: ['image'] }, okCtx()) + expect(refs[3].title).toBe('マグネット / ブラウン / ゴールド') + }) + + it('drops photos with no primary image', async () => { + const noImage = { ...(fixture as Json), data: { ...(fixture as { data: Json }).data, items: [ + { id: 999, memo: null, price: null, kawaii_count: 0, create_datetime: '2026-07-24 00:00:00', images: [], user: null, tags: [] }, + ] } } + const refs = await nailbook().search({ text: 'x', modalities: ['image'] }, recordingCtx(noImage).ctx) + expect(refs).toEqual([]) + }) + + it('respects the requested limit', async () => { + const refs = await nailbook().search({ text: 'x', modalities: ['image'], limit: 2 }, okCtx()) + expect(refs).toHaveLength(2) + }) + + it('returns [] on an empty result set', async () => { + const empty = { data: { total_count: 0, scrolling_key: null, items: [] }, result: true, status_code: 200 } + const refs = await nailbook().search({ text: 'zzz', modalities: ['image'] }, recordingCtx(empty).ctx) + expect(refs).toEqual([]) + }) +}) diff --git a/packages/provider-nailbook/src/index.ts b/packages/provider-nailbook/src/index.ts new file mode 100644 index 0000000..8172357 --- /dev/null +++ b/packages/provider-nailbook/src/index.ts @@ -0,0 +1,137 @@ +import { + defineProvider, referenceId, + type Reference, type RightsRecord, type NormalizedQuery, type ProviderContext, +} from '@refkit/core' + +export interface NailbookConfig { + /** Override the browser-like User-Agent sent with the search request. nailbook's + * edge serves data to browser-shaped clients; the default mirrors a desktop + * browser. Rarely needed. */ + userAgent?: string +} + +// nailbook.jp is a client-rendered SPA. Its /design/ list page embeds only a thin +// bootstrap (