From bdadfa8e06e579199357face375b332c23f97c09 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Wed, 1 Apr 2026 14:40:59 +0000 Subject: [PATCH 1/5] chore(deps): update dependency vitest to v4.1.2 --- package.json | 2 +- pnpm-lock.yaml | 156 +++++++++++++++++++++++-------------------------- 2 files changed, 73 insertions(+), 85 deletions(-) diff --git a/package.json b/package.json index 788645196..1b26f9dab 100644 --- a/package.json +++ b/package.json @@ -82,7 +82,7 @@ "tailwindcss": "4.2.2", "tsx": "4.21.0", "typescript-eslint": "^8.33.1", - "vitest": "4.0.18", + "vitest": "4.1.2", "zod": "^3.22.4" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5f61cd223..525941c19 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -175,8 +175,8 @@ importers: specifier: ^8.33.1 version: 8.57.2(eslint@10.1.0(jiti@2.6.1))(typescript@5.5.4) vitest: - specifier: 4.0.18 - version: 4.0.18(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.98.0)(tsx@4.21.0)(yaml@2.8.3) + specifier: 4.1.2 + version: 4.1.2(@types/node@24.12.0)(vite@7.3.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.98.0)(tsx@4.21.0)(yaml@2.8.3)) zod: specifier: ^3.22.4 version: 3.25.76 @@ -1461,8 +1461,8 @@ packages: resolution: {integrity: sha512-O/IEdcCUKkubz60tFbGA7ceITTAJsty+lBjNoorP4Z6XRqaFb/OjQjZODophEcuq68nKm6/0r+6/lLQ+XVpk8g==} engines: {node: '>=18.0.0'} - '@standard-schema/spec@1.0.0': - resolution: {integrity: sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==} + '@standard-schema/spec@1.1.0': + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} '@tailwindcss/node@4.2.2': resolution: {integrity: sha512-pXS+wJ2gZpVXqFaUEjojq7jzMpTGf8rU6ipJz5ovJV6PUGmlJ+jvIwGrzdHdQ80Sg+wmQxUFuoW1UAAwHNEdFA==} @@ -1802,34 +1802,34 @@ packages: peerDependencies: vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 - '@vitest/expect@4.0.18': - resolution: {integrity: sha512-8sCWUyckXXYvx4opfzVY03EOiYVxyNrHS5QxX3DAIi5dpJAAkyJezHCP77VMX4HKA2LDT/Jpfo8i2r5BE3GnQQ==} + '@vitest/expect@4.1.2': + resolution: {integrity: sha512-gbu+7B0YgUJ2nkdsRJrFFW6X7NTP44WlhiclHniUhxADQJH5Szt9mZ9hWnJPJ8YwOK5zUOSSlSvyzRf0u1DSBQ==} - '@vitest/mocker@4.0.18': - resolution: {integrity: sha512-HhVd0MDnzzsgevnOWCBj5Otnzobjy5wLBe4EdeeFGv8luMsGcYqDuFRMcttKWZA5vVO8RFjexVovXvAM4JoJDQ==} + '@vitest/mocker@4.1.2': + resolution: {integrity: sha512-Ize4iQtEALHDttPRCmN+FKqOl2vxTiNUhzobQFFt/BM1lRUTG7zRCLOykG/6Vo4E4hnUdfVLo5/eqKPukcWW7Q==} peerDependencies: msw: ^2.4.9 - vite: ^6.0.0 || ^7.0.0-0 + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 peerDependenciesMeta: msw: optional: true vite: optional: true - '@vitest/pretty-format@4.0.18': - resolution: {integrity: sha512-P24GK3GulZWC5tz87ux0m8OADrQIUVDPIjjj65vBXYG17ZeU3qD7r+MNZ1RNv4l8CGU2vtTRqixrOi9fYk/yKw==} + '@vitest/pretty-format@4.1.2': + resolution: {integrity: sha512-dwQga8aejqeuB+TvXCMzSQemvV9hNEtDDpgUKDzOmNQayl2OG241PSWeJwKRH3CiC+sESrmoFd49rfnq7T4RnA==} - '@vitest/runner@4.0.18': - resolution: {integrity: sha512-rpk9y12PGa22Jg6g5M3UVVnTS7+zycIGk9ZNGN+m6tZHKQb7jrP7/77WfZy13Y/EUDd52NDsLRQhYKtv7XfPQw==} + '@vitest/runner@4.1.2': + resolution: {integrity: sha512-Gr+FQan34CdiYAwpGJmQG8PgkyFVmARK8/xSijia3eTFgVfpcpztWLuP6FttGNfPLJhaZVP/euvujeNYar36OQ==} - '@vitest/snapshot@4.0.18': - resolution: {integrity: sha512-PCiV0rcl7jKQjbgYqjtakly6T1uwv/5BQ9SwBLekVg/EaYeQFPiXcgrC2Y7vDMA8dM1SUEAEV82kgSQIlXNMvA==} + '@vitest/snapshot@4.1.2': + resolution: {integrity: sha512-g7yfUmxYS4mNxk31qbOYsSt2F4m1E02LFqO53Xpzg3zKMhLAPZAjjfyl9e6z7HrW6LvUdTwAQR3HHfLjpko16A==} - '@vitest/spy@4.0.18': - resolution: {integrity: sha512-cbQt3PTSD7P2OARdVW3qWER5EGq7PHlvE+QfzSC0lbwO+xnt7+XH06ZzFjFRgzUX//JmpxrCu92VdwvEPlWSNw==} + '@vitest/spy@4.1.2': + resolution: {integrity: sha512-DU4fBnbVCJGNBwVA6xSToNXrkZNSiw59H8tcuUspVMsBDBST4nfvsPsEHDHGtWRRnqBERBQu7TrTKskmjqTXKA==} - '@vitest/utils@4.0.18': - resolution: {integrity: sha512-msMRKLMVLWygpK3u2Hybgi4MNjcYJvwTb0Ru09+fOyCXIgT5raYP041DRRdiJiI3k/2U6SEbAETB3YtBrUkCFA==} + '@vitest/utils@4.1.2': + resolution: {integrity: sha512-xw2/TiX82lQHA06cgbqRKFb5lCAy3axQ4H4SoUFhUsg+wztiet+co86IAMDtF6Vm1hc7J6j09oh/rgDn+JdKIQ==} acorn-jsx@5.3.2: resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} @@ -1917,8 +1917,8 @@ packages: ccount@2.0.1: resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==} - chai@6.2.1: - resolution: {integrity: sha512-p4Z49OGG5W/WBCPSS/dH3jQ73kD6tiMmUM+bckNK6Jr5JHMG3k9bg/BvKR8lKmtVBKmOiuVaV2ws8s9oSbwysg==} + chai@6.2.2: + resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} engines: {node: '>=18'} character-entities-html4@2.1.0: @@ -2331,9 +2331,6 @@ packages: resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==} engines: {node: '>=0.12'} - es-module-lexer@1.7.0: - resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} - es-module-lexer@2.0.0: resolution: {integrity: sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==} @@ -2412,8 +2409,8 @@ packages: eventemitter3@5.0.1: resolution: {integrity: sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==} - expect-type@1.2.2: - resolution: {integrity: sha512-JhFGDVJ7tmDJItKhYgJCGLOWjuK9vPxiXoUFLwLDc99NlmklilbiQJwoctZtt13+xMw91MCk/REan6MWHqDjyA==} + expect-type@1.3.0: + resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} engines: {node: '>=12.0.0'} exsolve@1.0.7: @@ -3401,8 +3398,8 @@ packages: resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} engines: {node: '>= 0.8'} - std-env@3.10.0: - resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} + std-env@4.0.0: + resolution: {integrity: sha512-zUMPtQ/HBY3/50VbpkupYHbRroTRZJPRLvreamgErJVys0ceuzMkD44J/QjqhHjOzK42GQ3QZIeFG1OYfOtKqQ==} stream-replace-string@2.0.0: resolution: {integrity: sha512-TlnjJ1C0QrmxRNrON00JvaFFlNh5TTG00APw23j74ET7gkQpTASi6/L2fuiav8pzK715HXtUeClpBTw2NPSn6w==} @@ -3457,8 +3454,8 @@ packages: resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} engines: {node: '>=12.0.0'} - tinyrainbow@3.0.3: - resolution: {integrity: sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==} + tinyrainbow@3.1.0: + resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==} engines: {node: '>=14.0.0'} to-regex-range@5.0.1: @@ -3703,20 +3700,21 @@ packages: vite: optional: true - vitest@4.0.18: - resolution: {integrity: sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ==} + vitest@4.1.2: + resolution: {integrity: sha512-xjR1dMTVHlFLh98JE3i/f/WePqJsah4A0FK9cc8Ehp9Udk0AZk6ccpIZhh1qJ/yxVWRZ+Q54ocnD8TXmkhspGg==} engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} hasBin: true peerDependencies: '@edge-runtime/vm': '*' '@opentelemetry/api': ^1.9.0 '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 - '@vitest/browser-playwright': 4.0.18 - '@vitest/browser-preview': 4.0.18 - '@vitest/browser-webdriverio': 4.0.18 - '@vitest/ui': 4.0.18 + '@vitest/browser-playwright': 4.1.2 + '@vitest/browser-preview': 4.1.2 + '@vitest/browser-webdriverio': 4.1.2 + '@vitest/ui': 4.1.2 happy-dom: '*' jsdom: '*' + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 peerDependenciesMeta: '@edge-runtime/vm': optional: true @@ -5371,7 +5369,7 @@ snapshots: dependencies: tslib: 2.8.1 - '@standard-schema/spec@1.0.0': {} + '@standard-schema/spec@1.1.0': {} '@tailwindcss/node@4.2.2': dependencies: @@ -5752,44 +5750,46 @@ snapshots: transitivePeerDependencies: - supports-color - '@vitest/expect@4.0.18': + '@vitest/expect@4.1.2': dependencies: - '@standard-schema/spec': 1.0.0 + '@standard-schema/spec': 1.1.0 '@types/chai': 5.2.2 - '@vitest/spy': 4.0.18 - '@vitest/utils': 4.0.18 - chai: 6.2.1 - tinyrainbow: 3.0.3 + '@vitest/spy': 4.1.2 + '@vitest/utils': 4.1.2 + chai: 6.2.2 + tinyrainbow: 3.1.0 - '@vitest/mocker@4.0.18(vite@7.3.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.98.0)(tsx@4.21.0)(yaml@2.8.3))': + '@vitest/mocker@4.1.2(vite@7.3.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.98.0)(tsx@4.21.0)(yaml@2.8.3))': dependencies: - '@vitest/spy': 4.0.18 + '@vitest/spy': 4.1.2 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: vite: 7.3.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.98.0)(tsx@4.21.0)(yaml@2.8.3) - '@vitest/pretty-format@4.0.18': + '@vitest/pretty-format@4.1.2': dependencies: - tinyrainbow: 3.0.3 + tinyrainbow: 3.1.0 - '@vitest/runner@4.0.18': + '@vitest/runner@4.1.2': dependencies: - '@vitest/utils': 4.0.18 + '@vitest/utils': 4.1.2 pathe: 2.0.3 - '@vitest/snapshot@4.0.18': + '@vitest/snapshot@4.1.2': dependencies: - '@vitest/pretty-format': 4.0.18 + '@vitest/pretty-format': 4.1.2 + '@vitest/utils': 4.1.2 magic-string: 0.30.21 pathe: 2.0.3 - '@vitest/spy@4.0.18': {} + '@vitest/spy@4.1.2': {} - '@vitest/utils@4.0.18': + '@vitest/utils@4.1.2': dependencies: - '@vitest/pretty-format': 4.0.18 - tinyrainbow: 3.0.3 + '@vitest/pretty-format': 4.1.2 + convert-source-map: 2.0.0 + tinyrainbow: 3.1.0 acorn-jsx@5.3.2(acorn@8.16.0): dependencies: @@ -5950,7 +5950,7 @@ snapshots: ccount@2.0.1: {} - chai@6.2.1: {} + chai@6.2.2: {} character-entities-html4@2.1.0: {} @@ -6368,8 +6368,6 @@ snapshots: entities@7.0.1: {} - es-module-lexer@1.7.0: {} - es-module-lexer@2.0.0: {} esbuild@0.27.4: @@ -6485,7 +6483,7 @@ snapshots: eventemitter3@5.0.1: {} - expect-type@1.2.2: {} + expect-type@1.3.0: {} exsolve@1.0.7: {} @@ -7830,7 +7828,7 @@ snapshots: statuses@2.0.2: {} - std-env@3.10.0: {} + std-env@4.0.0: {} stream-replace-string@2.0.0: {} @@ -7882,7 +7880,7 @@ snapshots: fdir: 6.5.0(picomatch@4.0.3) picomatch: 4.0.3 - tinyrainbow@3.0.3: {} + tinyrainbow@3.1.0: {} to-regex-range@5.0.1: dependencies: @@ -8063,42 +8061,32 @@ snapshots: optionalDependencies: vite: 7.3.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.98.0)(tsx@4.21.0)(yaml@2.8.3) - vitest@4.0.18(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.98.0)(tsx@4.21.0)(yaml@2.8.3): - dependencies: - '@vitest/expect': 4.0.18 - '@vitest/mocker': 4.0.18(vite@7.3.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.98.0)(tsx@4.21.0)(yaml@2.8.3)) - '@vitest/pretty-format': 4.0.18 - '@vitest/runner': 4.0.18 - '@vitest/snapshot': 4.0.18 - '@vitest/spy': 4.0.18 - '@vitest/utils': 4.0.18 - es-module-lexer: 1.7.0 - expect-type: 1.2.2 + vitest@4.1.2(@types/node@24.12.0)(vite@7.3.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.98.0)(tsx@4.21.0)(yaml@2.8.3)): + dependencies: + '@vitest/expect': 4.1.2 + '@vitest/mocker': 4.1.2(vite@7.3.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.98.0)(tsx@4.21.0)(yaml@2.8.3)) + '@vitest/pretty-format': 4.1.2 + '@vitest/runner': 4.1.2 + '@vitest/snapshot': 4.1.2 + '@vitest/spy': 4.1.2 + '@vitest/utils': 4.1.2 + es-module-lexer: 2.0.0 + expect-type: 1.3.0 magic-string: 0.30.21 obug: 2.1.1 pathe: 2.0.3 picomatch: 4.0.3 - std-env: 3.10.0 + std-env: 4.0.0 tinybench: 2.9.0 tinyexec: 1.0.4 tinyglobby: 0.2.15 - tinyrainbow: 3.0.3 + tinyrainbow: 3.1.0 vite: 7.3.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.98.0)(tsx@4.21.0)(yaml@2.8.3) why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 24.12.0 transitivePeerDependencies: - - jiti - - less - - lightningcss - msw - - sass - - sass-embedded - - stylus - - sugarss - - terser - - tsx - - yaml vscode-jsonrpc@8.2.0: {} From 298578fd49434c1902b7064d4fae9ff354bdcdff Mon Sep 17 00:00:00 2001 From: Suguru Inatomi Date: Thu, 2 Apr 2026 00:18:56 +0900 Subject: [PATCH 2/5] fix: move embed spec file out of pages directory Astro treats all .ts files in src/pages/ as routes. The spec file was registered as /embed/index.spec route, which crashes in vitest 4.1.2 because describe() now eagerly accesses runner.config via initSuite(). Co-Authored-By: Claude Opus 4.6 (1M context) --- .../index.spec.ts => libs/embed/fetchPageMetadata.spec.ts} | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename src/{pages/embed/index.spec.ts => libs/embed/fetchPageMetadata.spec.ts} (98%) diff --git a/src/pages/embed/index.spec.ts b/src/libs/embed/fetchPageMetadata.spec.ts similarity index 98% rename from src/pages/embed/index.spec.ts rename to src/libs/embed/fetchPageMetadata.spec.ts index 4781b4200..90e9198c8 100644 --- a/src/pages/embed/index.spec.ts +++ b/src/libs/embed/fetchPageMetadata.spec.ts @@ -1,5 +1,5 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { fetchPageMetadata } from './index'; +import { fetchPageMetadata } from '../../pages/embed/index'; // fetchをモック const mockFetch = vi.fn(); From 814243b705732a06ccd226d0acf0656208469914 Mon Sep 17 00:00:00 2001 From: Suguru Inatomi Date: Thu, 2 Apr 2026 09:29:10 +0900 Subject: [PATCH 3/5] refactor: extract fetchPageMetadata to src/libs/embed/ MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move fetchPageMetadata and its helpers from src/pages/embed/index.ts to src/libs/embed/fetchPageMetadata.ts to fix the dependency inversion (libs → pages). The page handler now imports from the library module. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/libs/embed/fetchPageMetadata.spec.ts | 2 +- src/libs/embed/fetchPageMetadata.ts | 136 +++++++++++++++++++++ src/pages/embed/index.ts | 148 +---------------------- 3 files changed, 140 insertions(+), 146 deletions(-) create mode 100644 src/libs/embed/fetchPageMetadata.ts diff --git a/src/libs/embed/fetchPageMetadata.spec.ts b/src/libs/embed/fetchPageMetadata.spec.ts index 90e9198c8..30168d3af 100644 --- a/src/libs/embed/fetchPageMetadata.spec.ts +++ b/src/libs/embed/fetchPageMetadata.spec.ts @@ -1,5 +1,5 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { fetchPageMetadata } from '../../pages/embed/index'; +import { fetchPageMetadata } from './fetchPageMetadata'; // fetchをモック const mockFetch = vi.fn(); diff --git a/src/libs/embed/fetchPageMetadata.ts b/src/libs/embed/fetchPageMetadata.ts new file mode 100644 index 000000000..d573eb7ac --- /dev/null +++ b/src/libs/embed/fetchPageMetadata.ts @@ -0,0 +1,136 @@ +import { load, type CheerioAPI } from 'cheerio'; +import pRetry, { AbortError } from 'p-retry'; + +// 定数 +const FETCH_TIMEOUT_MS = 10000; // 10 seconds per request +const AMAZON_URL_PREFIXES = ['https://www.amazon.co.jp/', 'https://amzn.asia/']; +const CHROME_USER_AGENT = + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36'; +const DEFAULT_USER_AGENT = 'blog.lacolaco.net'; + +// 型定義 +export interface PageMetadata { + title: string; + description: string; + imageUrl: string | null; + cacheControl: string | null; +} + +// ヘルパー関数 +function getUserAgent(url: string): string { + const isAmazonRequest = AMAZON_URL_PREFIXES.some((domain) => url.startsWith(domain)); + return isAmazonRequest ? CHROME_USER_AGENT : DEFAULT_USER_AGENT; +} + +function extractTitle($: CheerioAPI, fallbackUrl: string): string { + const metaOgTitle = $('head>meta[property="og:title"]').attr('content'); + if (metaOgTitle) return metaOgTitle; + + const metaTitle = $('head>meta[name="title"]').attr('content'); + if (metaTitle) return metaTitle; + + const docTitle = $('title').text(); + if (docTitle) return docTitle; + + return fallbackUrl; +} + +function extractDescription($: CheerioAPI): string { + const metaOgDescription = $('head>meta[property="og:description"]').attr('content'); + if (metaOgDescription) return metaOgDescription; + + const metaDescription = $('head>meta[name="description"]').attr('content'); + if (metaDescription) return metaDescription; + + const metaTwitterDescription = $('head>meta[name="twitter:description"]').attr('content'); + if (metaTwitterDescription) return metaTwitterDescription; + + return ''; +} + +function extractImageUrl($: CheerioAPI): string | null { + const metaOgImage = $('head>meta[property="og:image"]').attr('content'); + if (metaOgImage) return metaOgImage; + + const metaTwitterImage = $('head>meta[name="twitter:image"]').attr('content'); + if (metaTwitterImage) return metaTwitterImage; + + const metaImage = $('head>meta[name="image"]').attr('content'); + if (metaImage) return metaImage; + + const firstImg = $('img').first().attr('src'); + if (firstImg) return firstImg; + + return null; +} + +export const DEFAULT_CACHE_MAX_AGE = 60 * 60; // 1 hour + +export async function fetchPageMetadata(url: string): Promise { + const userAgent = getUserAgent(url); + + try { + const response = await pRetry( + async () => { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS); + + try { + const res = await fetch(url, { + signal: controller.signal, + headers: { + 'user-agent': userAgent, + accept: 'text/html', + 'accept-charset': 'utf-8', + 'accept-language': 'ja, en;q=0.9', + }, + }); + + clearTimeout(timeoutId); + + // 4xx系エラーはリトライしない + if (res.status >= 400 && res.status < 500) { + throw new AbortError(`Client error: ${res.status}`); + } + + // 5xx系エラーはリトライ対象 + if (!res.ok) { + throw new Error(`Server error: ${res.status}`); + } + + return res; + } catch (error) { + clearTimeout(timeoutId); + throw error; + } + }, + { + retries: 3, + minTimeout: 1000, // 1秒 + maxTimeout: 4000, // 4秒 + factor: 2, // 指数バックオフ係数 (1秒 → 2秒 → 4秒) + onFailedAttempt: (error) => { + console.warn(`Retry ${error.attemptNumber}/3: ${url}`); + }, + }, + ); + + const html = await response.text(); + const $ = load(html); + + return { + title: extractTitle($, url), + description: extractDescription($), + imageUrl: extractImageUrl($), + cacheControl: response.headers.get('cache-control'), + }; + } catch (error) { + console.error(`Failed to fetch ${url}:`, error); + return { + title: url, + description: '', + imageUrl: null, + cacheControl: 'max-age=60, must-revalidate', + }; + } +} diff --git a/src/pages/embed/index.ts b/src/pages/embed/index.ts index c3298e621..8b10c4fb8 100644 --- a/src/pages/embed/index.ts +++ b/src/pages/embed/index.ts @@ -1,31 +1,9 @@ import type { APIContext } from 'astro'; -import { load, type CheerioAPI } from 'cheerio'; -import pRetry, { AbortError } from 'p-retry'; import escapeHtml from 'escape-html'; +import { DEFAULT_CACHE_MAX_AGE, fetchPageMetadata, type PageMetadata } from '../../libs/embed/fetchPageMetadata'; export const prerender = false; -// 定数 -const DEFAULT_CACHE_MAX_AGE = 60 * 60; // 1 hour -const FETCH_TIMEOUT_MS = 10000; // 10 seconds per request -const AMAZON_URL_PREFIXES = ['https://www.amazon.co.jp/', 'https://amzn.asia/']; -const CHROME_USER_AGENT = - 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36'; -const DEFAULT_USER_AGENT = 'blog.lacolaco.net'; - -// 型定義 -interface PageMetadata { - title: string; - description: string; - imageUrl: string | null; - cacheControl: string | null; -} - -interface FetchConfig { - userAgent: string; - cacheTtl: number; -} - /** * `/embed` エンドポイントのハンドラー * このエンドポイントは、指定されたURLのメタデータを取得し、Webページカードとして埋め込むHTMLを生成します。 @@ -51,126 +29,6 @@ export async function GET(context: APIContext): Promise { }); } -// ヘルパー関数 -function getFetchConfig(url: string): FetchConfig { - const isAmazonRequest = AMAZON_URL_PREFIXES.some((domain) => url.startsWith(domain)); - return { - userAgent: isAmazonRequest ? CHROME_USER_AGENT : DEFAULT_USER_AGENT, - cacheTtl: DEFAULT_CACHE_MAX_AGE, - }; -} - -function extractTitle($: CheerioAPI, fallbackUrl: string): string { - const metaOgTitle = $('head>meta[property="og:title"]').attr('content'); - if (metaOgTitle) return metaOgTitle; - - const metaTitle = $('head>meta[name="title"]').attr('content'); - if (metaTitle) return metaTitle; - - const docTitle = $('title').text(); - if (docTitle) return docTitle; - - return fallbackUrl; -} - -function extractDescription($: CheerioAPI): string { - const metaOgDescription = $('head>meta[property="og:description"]').attr('content'); - if (metaOgDescription) return metaOgDescription; - - const metaDescription = $('head>meta[name="description"]').attr('content'); - if (metaDescription) return metaDescription; - - const metaTwitterDescription = $('head>meta[name="twitter:description"]').attr('content'); - if (metaTwitterDescription) return metaTwitterDescription; - - return ''; -} - -function extractImageUrl($: CheerioAPI): string | null { - const metaOgImage = $('head>meta[property="og:image"]').attr('content'); - if (metaOgImage) return metaOgImage; - - const metaTwitterImage = $('head>meta[name="twitter:image"]').attr('content'); - if (metaTwitterImage) return metaTwitterImage; - - const metaImage = $('head>meta[name="image"]').attr('content'); - if (metaImage) return metaImage; - - const firstImg = $('img').first().attr('src'); - if (firstImg) return firstImg; - - return null; -} - -export async function fetchPageMetadata(url: string): Promise { - const config = getFetchConfig(url); - - try { - const response = await pRetry( - async () => { - const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS); - - try { - const res = await fetch(url, { - signal: controller.signal, - headers: { - 'user-agent': config.userAgent, - accept: 'text/html', - 'accept-charset': 'utf-8', - 'accept-language': 'ja, en;q=0.9', - }, - }); - - clearTimeout(timeoutId); - - // 4xx系エラーはリトライしない - if (res.status >= 400 && res.status < 500) { - throw new AbortError(`Client error: ${res.status}`); - } - - // 5xx系エラーはリトライ対象 - if (!res.ok) { - throw new Error(`Server error: ${res.status}`); - } - - return res; - } catch (error) { - clearTimeout(timeoutId); - throw error; - } - }, - { - retries: 3, - minTimeout: 1000, // 1秒 - maxTimeout: 4000, // 4秒 - factor: 2, // 指数バックオフ係数 (1秒 → 2秒 → 4秒) - onFailedAttempt: (error) => { - console.warn(`Retry ${error.attemptNumber}/3: ${url}`); - }, - }, - ); - - const html = await response.text(); - const $ = load(html); - - return { - title: extractTitle($, url), - description: extractDescription($), - imageUrl: extractImageUrl($), - cacheControl: response.headers.get('cache-control'), - }; - } catch (error) { - console.error(`Failed to fetch ${url}:`, error); - return { - title: url, - description: '', - imageUrl: null, - cacheControl: 'max-age=60, must-revalidate', - }; - } -} - function getEmbedStyles(): string { return ` html, body { @@ -243,7 +101,7 @@ function getEmbedStyles(): string { .webpage-card { height: 7rem; } - + .webpage-card-image { display: block; } @@ -251,7 +109,7 @@ function getEmbedStyles(): string { `; } -function buildEmbedHtml(metadata: PageMetadata, url: string): string { +function buildEmbedHtml(metadata: Pick, url: string): string { const { title, imageUrl } = metadata; const displayDescription = new URL(url).hostname; From fcd91494bfd740c7a23462c3d331b3a4cd01583e Mon Sep 17 00:00:00 2001 From: Suguru Inatomi Date: Thu, 2 Apr 2026 10:05:05 +0900 Subject: [PATCH 4/5] fix: resolve relative image URLs and clarify retry log message - extractImageUrl now resolves relative URLs against the page base URL - Retry log message clarified from "Retry N/3" to "Attempt N/total failed" Addresses code-review feedback on PR #1342. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/libs/embed/fetchPageMetadata.spec.ts | 21 +++++++++++++++++ src/libs/embed/fetchPageMetadata.ts | 30 +++++++++++++----------- 2 files changed, 37 insertions(+), 14 deletions(-) diff --git a/src/libs/embed/fetchPageMetadata.spec.ts b/src/libs/embed/fetchPageMetadata.spec.ts index 30168d3af..ef4849944 100644 --- a/src/libs/embed/fetchPageMetadata.spec.ts +++ b/src/libs/embed/fetchPageMetadata.spec.ts @@ -109,6 +109,27 @@ describe('fetchPageMetadata', () => { expect(mockFetch).toHaveBeenCalledTimes(2); }); + it('相対URLの画像パスを絶対URLに解決する', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + headers: new Headers(), + text: () => + Promise.resolve(` + + + Relative Image Test + + + + `), + }); + + const result = await fetchPageMetadata('https://example.com/page'); + + expect(result.imageUrl).toBe('https://example.com/images/hero.jpg'); + }); + it('無効なURLでもエラーをスローせずフォールバックする', async () => { mockFetch.mockResolvedValue({ ok: false, diff --git a/src/libs/embed/fetchPageMetadata.ts b/src/libs/embed/fetchPageMetadata.ts index d573eb7ac..ac0ea400f 100644 --- a/src/libs/embed/fetchPageMetadata.ts +++ b/src/libs/embed/fetchPageMetadata.ts @@ -48,20 +48,22 @@ function extractDescription($: CheerioAPI): string { return ''; } -function extractImageUrl($: CheerioAPI): string | null { - const metaOgImage = $('head>meta[property="og:image"]').attr('content'); - if (metaOgImage) return metaOgImage; +function extractImageUrl($: CheerioAPI, baseUrl: string): string | null { + const candidates = [ + $('head>meta[property="og:image"]').attr('content'), + $('head>meta[name="twitter:image"]').attr('content'), + $('head>meta[name="image"]').attr('content'), + $('img').first().attr('src'), + ]; - const metaTwitterImage = $('head>meta[name="twitter:image"]').attr('content'); - if (metaTwitterImage) return metaTwitterImage; + const found = candidates.find((c) => c != null); + if (!found) return null; - const metaImage = $('head>meta[name="image"]').attr('content'); - if (metaImage) return metaImage; - - const firstImg = $('img').first().attr('src'); - if (firstImg) return firstImg; - - return null; + try { + return new URL(found, baseUrl).href; + } catch { + return found; + } } export const DEFAULT_CACHE_MAX_AGE = 60 * 60; // 1 hour @@ -110,7 +112,7 @@ export async function fetchPageMetadata(url: string): Promise { maxTimeout: 4000, // 4秒 factor: 2, // 指数バックオフ係数 (1秒 → 2秒 → 4秒) onFailedAttempt: (error) => { - console.warn(`Retry ${error.attemptNumber}/3: ${url}`); + console.warn(`Attempt ${error.attemptNumber}/${error.retriesLeft + error.attemptNumber} failed: ${url}`); }, }, ); @@ -121,7 +123,7 @@ export async function fetchPageMetadata(url: string): Promise { return { title: extractTitle($, url), description: extractDescription($), - imageUrl: extractImageUrl($), + imageUrl: extractImageUrl($, url), cacheControl: response.headers.get('cache-control'), }; } catch (error) { From be40cc5639d686f48fbe8f0321ab27e4446e3348 Mon Sep 17 00:00:00 2001 From: Suguru Inatomi Date: Thu, 2 Apr 2026 10:09:16 +0900 Subject: [PATCH 5/5] fix: skip empty string image URLs in extractImageUrl Use Boolean() truthy check instead of != null to skip empty content="" attributes, preventing baseUrl from being returned as imageUrl. Addresses code-review feedback on PR #1342. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/libs/embed/fetchPageMetadata.spec.ts | 21 +++++++++++++++++++++ src/libs/embed/fetchPageMetadata.ts | 2 +- 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/src/libs/embed/fetchPageMetadata.spec.ts b/src/libs/embed/fetchPageMetadata.spec.ts index ef4849944..e10b3cc24 100644 --- a/src/libs/embed/fetchPageMetadata.spec.ts +++ b/src/libs/embed/fetchPageMetadata.spec.ts @@ -130,6 +130,27 @@ describe('fetchPageMetadata', () => { expect(result.imageUrl).toBe('https://example.com/images/hero.jpg'); }); + it('空文字列のog:imageをスキップする', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + headers: new Headers(), + text: () => + Promise.resolve(` + + + Empty Image Test + + + + `), + }); + + const result = await fetchPageMetadata('https://example.com'); + + expect(result.imageUrl).toBeNull(); + }); + it('無効なURLでもエラーをスローせずフォールバックする', async () => { mockFetch.mockResolvedValue({ ok: false, diff --git a/src/libs/embed/fetchPageMetadata.ts b/src/libs/embed/fetchPageMetadata.ts index ac0ea400f..6b1efc71f 100644 --- a/src/libs/embed/fetchPageMetadata.ts +++ b/src/libs/embed/fetchPageMetadata.ts @@ -56,7 +56,7 @@ function extractImageUrl($: CheerioAPI, baseUrl: string): string | null { $('img').first().attr('src'), ]; - const found = candidates.find((c) => c != null); + const found = candidates.find(Boolean); if (!found) return null; try {