diff --git a/CHANGELOG.md b/CHANGELOG.md index b29d71c1..d2177284 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,11 @@ Format: [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) · [Semantic Ve `/shindan?job=` links 301 to `/me` for humans; social scrapers keep the share rewrite so OG is not lost (#259, #260). `/shindan` stays in the sitemap. SEO baseline recaptured: −1 URL (`/me/start`). +- Diagnostic share is measurement-led when an occupation is known (#237): + `{職業}のAI影響度は{点数}。あなたの仕事は?`. No-occupation `/shindan` + shares stay identity-only. Worktype OG cards and `/me` share follow the + same hook. Social unfurlers of `/me?id=` reuse the occupation score card. + SEO baseline recaptured: `/me` anchors +2 (`meShare`, `meShareOpen`). ### Changed diff --git a/ROADMAP.md b/ROADMAP.md index ba475d29..403ac6e4 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -13,7 +13,7 @@ Fields: ## Done — occupation-first /me consolidation, acq-2…acq-6 (`preview`, 2026-08-20) Shipped on `preview` / https://pre.mirai-shigoto.com. **Not** promoted to -`main`. Umbrella #233 stays open for #237 (share) and #236 (SEO). #234 stays +`main`. Umbrella #233 stays open for #236 (SEO). #234 stays open because it closes on a later entry number, not on the ship. - acq-2-funnel (#256 / #261): `shindan_start` + `shindan_step` (`value` 1..9). @@ -25,6 +25,8 @@ open because it closes on a later entry number, not on the ship. - Follow-ups: `/me` cream body (#266); desktop top nav 「自分の現在地」 (#267). - JA copy in `docs/ME_CONSOLIDATION.md` §4.6 signed by owner 2026-08-20 (`hidden_risk`: この仕事での進め方は、これから変えていけます). +- acq-7-share (#237): share text and worktype OG are measurement-led when a + job is known (`{職業}のAI影響度は{点数}`). No-occupation shares stay identity. ## Done — v1.8.0 release reconciliation (#175, closed 2026-07-17) diff --git a/api/shindan-share.ts b/api/shindan-share.ts index 37d60fcb..abd4907d 100644 --- a/api/shindan-share.ts +++ b/api/shindan-share.ts @@ -1,4 +1,9 @@ -import { trustedFetchOrigin, WorktypesProjectionSchema } from '../src/lib/og-helpers.js'; +import { + DetailRecordSchema, + padId, + trustedFetchOrigin, + WorktypesProjectionSchema, +} from '../src/lib/og-helpers.js'; import { addShindanOccupationContext, parseShindanBaseState, @@ -6,6 +11,7 @@ import { import { buildShindanShareMetadata, renderShindanShareHtml, + type ShindanShareJobContext, } from '../src/site/shindan-share-html.js'; export const config = { @@ -58,7 +64,9 @@ export async function renderShindanShareResponse( }); } - const metadata = state ? buildShindanShareMetadata(origin, state) : null; + const jobId = requestUrl.searchParams.get('job'); + const jobContext = jobId ? await fetchShareJobContext(origin, jobId, fetchImpl) : null; + const metadata = state ? buildShindanShareMetadata(origin, state, jobContext) : null; const html = renderShindanShareHtml(await basePageResponse.text(), metadata); return new Response(html, { status: 200, @@ -70,6 +78,32 @@ export async function renderShindanShareResponse( }); } +async function fetchShareJobContext( + origin: string, + jobId: string, + fetchImpl: FetchLike, +): Promise { + let paddedId: string; + try { + paddedId = padId(jobId); + } catch { + return null; + } + const detailRes = await fetchImpl(new URL(`/data.detail/${paddedId}.json`, origin), { + headers: { Accept: 'application/json' }, + }).catch(() => null); + if (!detailRes?.ok) return null; + const detailRaw: unknown = await detailRes.json().catch(() => null); + const parsed = DetailRecordSchema.safeParse(detailRaw); + if (!parsed.success) return null; + const title = parsed.data.title?.ja; + if (!title) return null; + return { + title, + score: parsed.data.ai_risk?.score ?? null, + }; +} + export default function handler(request: Request): Promise { return renderShindanShareResponse(request); } diff --git a/docs/ME_CONSOLIDATION.md b/docs/ME_CONSOLIDATION.md index 8ff64a7a..71c7bcad 100644 --- a/docs/ME_CONSOLIDATION.md +++ b/docs/ME_CONSOLIDATION.md @@ -1,7 +1,8 @@ # /me Consolidation — Occupation-First Diagnostic (Design) Status: screens 1–3 and routing shipped on `preview` (2026-08-20). JA copy -signed 2026-08-20. Remaining: #237 share, #236 SEO. Extends [`WORKTYPE_VIRALITY.md`](./WORKTYPE_VIRALITY.md) +signed 2026-08-20. Share unit is measurement-led when a job is known (#237). +Remaining: #236 SEO. Extends [`WORKTYPE_VIRALITY.md`](./WORKTYPE_VIRALITY.md) and [`WORKTYPE_DIAGNOSTIC.md`](./WORKTYPE_DIAGNOSTIC.md). Those docs rework how the diagnostic result is **named, surfaced and spread**, and leave the 3-axis / 8-family / 24-variant scoring system unchanged. This doc keeps that scoring system @@ -325,7 +326,10 @@ own vocabulary**. It did not consider the AI-impact score, because at the time t result did not reliably contain one. After consolidation a score is present on screen 1, so the option becomes -available and should be re-decided. Tracked in #237; not settled by this doc. +available. Owner ruled **measurement-led** (2026-08-20, #237): share text and +the `/api/og` worktype card lead with `{職業}のAI影響度は{点数}` when a job +is known. No-occupation `/shindan` shares stay identity-only because there is +no number. `WORKTYPE_VIRALITY.md` §S5 is amended in the same change. **§4.B — entry.** That doc plans entry via global nav plus a homepage first-screen band. The nav entry shipped (`TopNav.astro:37`, `MobileNav.astro:65`) and `/shindan` @@ -359,7 +363,9 @@ Order: 3. **`[consolidate]`** — screens 2 and 3, the no-occupation branch, the redirect. Requires funnel instrumentation to exist first (§8). Shipped on `preview` (#256–#260, PRs #261–#265). Not promoted to `main`. -4. **`[share]`** (#237) — only meaningful once a score is on screen 1. +4. **`[share]`** (#237) — measurement-led when a job is known; identity-only + on the no-occupation `/shindan` result. Share text and worktype OG card + decided together. 5. **`[seo]`** (#236) — only decidable once the page shape is settled. **#235 (rarity) is independent** and can be done at any point. @@ -418,8 +424,7 @@ Still open: 1. **Public name beyond `/shindan`.** Owner locked `/shindan` as the no-occupation entry (2026-08-17). A 転職-anchored path remains a #236 positioning decision. `NO_OCC_PATH` is still the one constant. -2. **Share unit** (#237) — identity name vs AI-impact number. Not settled here. -3. **Indexable page space** (#236) — only decidable now that the page shape is +2. **Indexable page space** (#236) — only decidable now that the page shape is shipped. -4. **Whether the 24-type roster still earns a route** once screen 3 exists and +3. **Whether the 24-type roster still earns a route** once screen 3 exists and there is traffic to judge with. Deliberately deferred, not decided. diff --git a/docs/WORKTYPE_VIRALITY.md b/docs/WORKTYPE_VIRALITY.md index 9f4995d8..a04ec06d 100644 --- a/docs/WORKTYPE_VIRALITY.md +++ b/docs/WORKTYPE_VIRALITY.md @@ -48,9 +48,13 @@ framework people already know** — not from a proprietary code. - **S4 — Keep the game layer, kill the repetition.** 図鑑 / めくる / trophy→dare stays (it is the viral loop). Rotate the locked-label and next-move phrasings so nothing reads copy-pasted. -- **S5 — Single share hero.** The share unit is **one memorable identity** (the - variant name + one line), not family+variant+code. Axes / family / rarity / - "depth" live inside the page, not in the share hook. +- **S5 — Single share hero.** When an occupation (and therefore an AI-impact + score) is known, the share unit is **that measurement** (job name + score + + 「あなたの仕事は?」), not the variant name. Recipients recognise jobs, not + our type vocabulary (#237). When there is no occupation, the share unit + stays the memorable identity (variant name + one line). Axes / family / + rarity / "depth" live inside the page, not in the share hook. Do not put + the internal `CPK`-style code in the share unit. - **S6 — Piggyback MBTI (editorial).** A new content line 「MBTIタイプ × AI時代の働き方」 connects each already-known type to our occupation + AI-impact (AIOIS-10) data and funnels into the diagnostic. It rides diff --git a/middleware.ts b/middleware.ts index 59733da0..617f5b0f 100644 --- a/middleware.ts +++ b/middleware.ts @@ -56,6 +56,7 @@ import { classifyClientKind, deliveryIdentity, isBotUserAgent, + isShareUnfurlerUserAgent, shouldSendMpHit, buildMpPayload, classifyGeoReferral, @@ -64,6 +65,7 @@ import { } from './src/lib/middleware-helpers.js'; import { fetchWithTimeout } from './src/lib/http-client.js'; import { + meOccupationOgRewriteTarget, noOccAliasRedirectTarget, shindanOccupationRedirectTarget, shindanShareRewriteTarget, @@ -106,6 +108,11 @@ export default function middleware(request: Request, context: RequestContext): R return Response.redirect(occupationTarget, 301); } + const meOgTarget = meOccupationOgRewriteTarget(url); + if (meOgTarget && isShareUnfurlerUserAgent(ua)) { + return rewrite(meOgTarget); + } + const shareTarget = shindanShareRewriteTarget(url); const routeResponse = shareTarget ? rewrite(shareTarget) : next(); diff --git a/src/lib/middleware-helpers.ts b/src/lib/middleware-helpers.ts index 414b83ff..ab4663ac 100644 --- a/src/lib/middleware-helpers.ts +++ b/src/lib/middleware-helpers.ts @@ -68,6 +68,17 @@ export function isBotUserAgent(ua: string): boolean { return BOT_UA_RE.test(ua); } +/** + * Social unfurlers that fetch OG tags for a timeline card. + * Narrower than `isBotUserAgent` so Googlebot still sees the canonical `/me`. + */ +export const SHARE_UNFURLER_UA_RE = + /\b(twitterbot|facebookexternalhit|slackbot|discordbot|linkedinbot|whatsapp|telegrambot)\b/i; + +export function isShareUnfurlerUserAgent(ua: string): boolean { + return SHARE_UNFURLER_UA_RE.test(ua); +} + /** * Known AI / LLM agents, mapped to the canonical `agent_name` sent to GA4. * diff --git a/src/lib/og-renderers/worktype.test.ts b/src/lib/og-renderers/worktype.test.ts index 5f34821d..ce021b89 100644 --- a/src/lib/og-renderers/worktype.test.ts +++ b/src/lib/og-renderers/worktype.test.ts @@ -11,6 +11,7 @@ test('worktype OG context uses recomputed job gap copy', () => { title: 'データ職業', worktypeCode: 'CDB', worktypeName: 'ものづくり設計家', + score: 8.1, }); assert.equal(gap, 'hidden_risk'); @@ -18,3 +19,5 @@ test('worktype OG context uses recomputed job gap copy', () => { assert.match(context, /データ職業 \/ ものづくり設計家/); assert.match(context, /働き方を更新する余地があります/); }); + + diff --git a/src/lib/og-renderers/worktype.ts b/src/lib/og-renderers/worktype.ts index bf31686d..8fd68267 100644 --- a/src/lib/og-renderers/worktype.ts +++ b/src/lib/og-renderers/worktype.ts @@ -21,6 +21,7 @@ import { createElement as h } from 'react'; import type { ReactElement } from 'react'; import { DetailRecordSchema, + RISK_COLORS, WorktypesProjectionSchema, loadGoogleFont, padId, @@ -68,6 +69,7 @@ export interface WorktypeJobContext { readonly title: string; readonly worktypeCode: FamilyCode; readonly worktypeName?: string; + readonly score: number | null; } export async function renderWorktypeOgCard( @@ -94,16 +96,22 @@ export async function renderWorktypeOgCard( const projection = worktypesParsed.data; const variantCopy = resolveVariantCopy(input.family, input.variant); const visual = WORKTYPE_CARDS[input.family]; - const accent = visual.accent; - const sharePrompt = SHARE.challengeHooks[0] ?? 'あなたの1枚もめくってみる?'; const jobContext = input.job ? await fetchJobContext(url, input.job, projection) : null; + const score = jobContext?.score ?? null; + const scoreLabel = score != null && !Number.isNaN(score) ? String(score) : null; + const scoreColor = + score != null ? (RISK_COLORS[Math.round(score)] ?? visual.accent) : visual.accent; + const accent = scoreLabel ? scoreColor : visual.accent; + const sharePrompt = scoreLabel + ? SHARE.challengeHookWithJob + : (SHARE.challengeHooks[0] ?? 'あなたの1枚もめくってみる?'); const computedGap = jobContext ? classifyShindanGap(input.family, jobContext.worktypeCode).kind : undefined; const gapLine = computedGap ? GAP[computedGap].label : ''; const contextLine = buildWorktypeContextCopy(computedGap, gapLine, jobContext); - const featureLabel = buildWorktypeFeatureLabel(input.family); - const introLabel = variantCopy.name; + const featureLabel = scoreLabel ? `${LABELS.featureName} / AI 影響` : buildWorktypeFeatureLabel(input.family); + const introLabel = scoreLabel && jobContext ? jobContext.title : variantCopy.name; const subsetText = [ visual.glyphSet, @@ -115,6 +123,8 @@ export async function renderWorktypeOgCard( DISCLAIMER, FOOTER_LEFT, SHARE.hashtag, + SHARE.challengeHookWithJob, + scoreLabel ? `${scoreLabel} / 10` : '', FRAME_SUBSET, ].join(' '); @@ -135,6 +145,8 @@ export async function renderWorktypeOgCard( sharePrompt, variantCopy, visual, + scoreLabel, + scoreColor, }) : renderWideCard({ accent, @@ -144,6 +156,8 @@ export async function renderWorktypeOgCard( sharePrompt, variantCopy, visual, + scoreLabel, + scoreColor, }), { width: isSquare ? 1080 : 1200, @@ -166,6 +180,8 @@ interface CardLayoutData { readonly sharePrompt: string; readonly variantCopy: WorktypeVariant; readonly visual: { readonly character: string; readonly accent: string; readonly glyphSet: string }; + readonly scoreLabel: string | null; + readonly scoreColor: string; } function renderWideCard(data: CardLayoutData): ReactElement { @@ -182,7 +198,9 @@ function renderWideCard(data: CardLayoutData): ReactElement { marginTop: '24px', }, }, - characterBlock(data.visual.character, data.accent, 270, 152), + data.scoreLabel + ? scoreBadge(data.scoreLabel, data.scoreColor, 270) + : characterBlock(data.visual.character, data.accent, 270, 152), h( 'div', { @@ -221,7 +239,7 @@ function renderWideCard(data: CardLayoutData): ReactElement { color: COLORS.ink, }, }, - data.variantCopy.catch, + data.scoreLabel ? data.variantCopy.name : data.variantCopy.catch, ), h( 'div', @@ -269,7 +287,9 @@ function renderSquareCard(data: CardLayoutData): ReactElement { }, }, labelText(data.featureLabel, data.accent, 28), - characterBlock(data.visual.character, data.accent, 330, 190), + data.scoreLabel + ? scoreBadge(data.scoreLabel, data.scoreColor, 280) + : characterBlock(data.visual.character, data.accent, 330, 190), h( 'div', { @@ -296,7 +316,7 @@ function renderSquareCard(data: CardLayoutData): ReactElement { color: COLORS.ink, }, }, - data.variantCopy.catch, + data.scoreLabel ? data.variantCopy.name : data.variantCopy.catch, ), h( 'div', @@ -324,6 +344,54 @@ function renderSquareCard(data: CardLayoutData): ReactElement { ]); } +function scoreBadge(scoreLabel: string, color: string, size: number): ReactElement { + return h( + 'div', + { + style: { + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + justifyContent: 'center', + background: COLORS.bg2, + border: `4px solid ${color}`, + color, + width: `${size}px`, + height: `${size}px`, + borderRadius: '24px', + flexShrink: 0, + }, + }, + h( + 'div', + { + style: { + display: 'flex', + fontFamily: 'NotoSerifJP', + fontSize: size > 270 ? '160px' : '140px', + fontWeight: 600, + lineHeight: 1, + }, + }, + scoreLabel, + ), + h( + 'div', + { + style: { + display: 'flex', + fontSize: '28px', + fontWeight: 600, + marginTop: '-4px', + color: COLORS.muted, + letterSpacing: '0.04em', + }, + }, + '/ 10', + ), + ); +} + function characterBlock(character: string, accent: string, size: number, fontSize: number): ReactElement { return h( 'div', @@ -466,6 +534,7 @@ async function fetchJobContext( title, worktypeCode: worktypeRecord.code, worktypeName: FAMILIES[worktypeRecord.code].name, + score: detailParsed.data.ai_risk?.score ?? null, }; } diff --git a/src/lib/shindan-share-route.test.ts b/src/lib/shindan-share-route.test.ts index 18e831fe..90aff20f 100644 --- a/src/lib/shindan-share-route.test.ts +++ b/src/lib/shindan-share-route.test.ts @@ -1,8 +1,9 @@ import { describe, test } from 'node:test'; import { strict as assert } from 'node:assert'; -import { isBotUserAgent } from './middleware-helpers.js'; +import { isBotUserAgent, isShareUnfurlerUserAgent } from './middleware-helpers.js'; import { + meOccupationOgRewriteTarget, noOccAliasRedirectTarget, shindanOccupationRedirectTarget, shindanShareRewriteTarget, @@ -53,6 +54,20 @@ describe('shindan routing (#259 / #260 lock)', () => { assert.equal(target?.searchParams.has('gap'), false); }); + test('/me?id= rewrites to the occupation page for score OG (#237)', () => { + assert.equal( + meOccupationOgRewriteTarget(new URL('https://mirai-shigoto.com/me?id=133'))?.toString(), + 'https://mirai-shigoto.com/133', + ); + assert.equal( + meOccupationOgRewriteTarget(new URL('https://mirai-shigoto.com/me?id=404'))?.toString(), + 'https://mirai-shigoto.com/occupations/404', + ); + assert.equal(meOccupationOgRewriteTarget(new URL('https://mirai-shigoto.com/me')), null); + assert.equal(isShareUnfurlerUserAgent('Twitterbot/1.0'), true); + assert.equal(isShareUnfurlerUserAgent('Googlebot/2.1'), false); + }); + test('social scrapers still match the share rewrite on occupation-bearing links', () => { const url = new URL( 'https://mirai-shigoto.com/shindan?self=RPK&variant=mediator&axes=3-0%2F2-1%2F2-1&job=133', diff --git a/src/lib/shindan-share-route.ts b/src/lib/shindan-share-route.ts index 3b9da88a..70166404 100644 --- a/src/lib/shindan-share-route.ts +++ b/src/lib/shindan-share-route.ts @@ -39,6 +39,21 @@ export function shindanOccupationRedirectTarget(requestUrl: URL): URL | null { return target; } +/** + * `/me?id=` is a client-rendered page; its static OG is the generic /me card. + * Social unfurlers rewrite to the occupation page, which already has the + * score-led OG card (#237). Humans keep `/me?id=`. + */ +export function meOccupationOgRewriteTarget(requestUrl: URL): URL | null { + if (requestUrl.pathname !== '/me') return null; + const idRaw = requestUrl.searchParams.get('id'); + if (!idRaw || !JOB_ID_RE.test(idRaw)) return null; + const id = Number(idRaw); + if (id === 0) return null; + const path = id === 404 ? '/occupations/404' : `/${id}`; + return new URL(path, requestUrl.origin); +} + export function shindanShareRewriteTarget(requestUrl: URL): URL | null { if (requestUrl.pathname !== '/shindan' || !requestUrl.searchParams.has('self')) { return null; diff --git a/src/pages/_me-inline.js b/src/pages/_me-inline.js index baf2282b..f78cfba4 100644 --- a/src/pages/_me-inline.js +++ b/src/pages/_me-inline.js @@ -39,6 +39,8 @@ var $gapAction = document.getElementById('meGapAction'); var $gapMeter = document.getElementById('meGapMeter'); var $quizCopy = document.getElementById('meQuizCopy'); + var $share = document.getElementById('meShare'); + var $shareOpen = document.getElementById('meShareOpen'); var WORKTYPES_URL = '/data.worktypes.json'; var quizCopy = null; @@ -339,6 +341,7 @@ renderRankList(pos); renderSimilar(pos); resetQuizForJob(); + if ($share) $share.hidden = false; if (options && options.restoredQuiz) { restoreQuizResult(options.restoredQuiz); } @@ -805,9 +808,50 @@ }; } + function currentSharePayload() { + if (!currentJobId || !positionsData || !positionsData.positions) return null; + var pos = positionsData.positions[currentJobId]; + if (!pos || pos.summary.aiRisk == null) return null; + var copy = readQuizCopy(); + var share = copy && copy.share ? copy.share : {}; + var template = share.textTemplateWithJob || '#AI働き方診断 {職業}のAI影響度は{点数}。あなたの仕事は? {リンク}'; + var url = location.origin + location.pathname + (location.search || ''); + var text = template + .replace(/\{職業\}/g, pos.nameJa) + .replace(/\{点数\}/g, pos.summary.aiRisk + '/10') + .replace(/\{リンク\}/g, url) + .replace(/\s+/g, ' ') + .trim(); + return { title: pos.nameJa + 'のAI影響度', text: text, url: url, jobId: currentJobId }; + } + + function openMeShare() { + var payload = currentSharePayload(); + if (!payload) return; + ga('share_click', { platform: typeof navigator.share === 'function' ? 'native' : 'x', occupation_id: payload.jobId }); + if (typeof navigator.share === 'function') { + navigator.share({ + title: payload.title, + text: payload.text.replace(payload.url, '').replace(/\s+/g, ' ').trim(), + url: payload.url + }).catch(function () {}); + return; + } + window.open( + 'https://x.com/intent/post?text=' + encodeURIComponent(payload.text), + '_blank', + 'noopener,noreferrer' + ); + } + + function wireShare() { + if ($shareOpen) $shareOpen.addEventListener('click', openMeShare); + } + // ── init ─────────────────────────────────────────────────────── function init() { wireQuiz(); + wireShare(); var urlId = readUrlId(); var urlQuiz = readUrlQuiz(); // Pre-load positions + search so first selection is instant. diff --git a/src/pages/_me-share.test.ts b/src/pages/_me-share.test.ts new file mode 100644 index 00000000..561f4c9f --- /dev/null +++ b/src/pages/_me-share.test.ts @@ -0,0 +1,33 @@ +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { describe, test } from 'node:test'; +import { strict as assert } from 'node:assert'; + +const meJs = readFileSync(join(import.meta.dirname, '_me-inline.js'), 'utf8'); +const meAstro = readFileSync(join(import.meta.dirname, 'me.astro'), 'utf8'); +const shindanJs = readFileSync(join(import.meta.dirname, '_shindan.js'), 'utf8'); + +describe('/me and /shindan measurement-led share (#237)', () => { + test('/me shows a share control after the score, hidden until a job is selected', () => { + const statsAt = meAstro.indexOf('id="meStatRisk"'); + const shareAt = meAstro.indexOf('id="meShare"'); + const quizAt = meAstro.indexOf('id="meQuizCta"'); + assert.ok(statsAt > 0 && shareAt > statsAt && quizAt > shareAt); + assert.match(meAstro, /id="meShare" hidden/); + assert.match(meAstro, /この数字をシェア/); + }); + + test('/me share text uses the job score, not the type name', () => { + assert.match(meJs, /textTemplateWithJob/); + assert.match(meJs, /function currentSharePayload/); + assert.match(meJs, /pos\.summary\.aiRisk \+ '\/10'/); + assert.match(meJs, /ga\('share_click'/); + assert.doesNotMatch(meJs, /私は【/); + }); + + test('/shindan share switches to the job template when a score is known', () => { + assert.match(shindanJs, /function fillShareTemplate/); + assert.match(shindanJs, /textTemplateWithJob/); + assert.match(shindanJs, /AI影響度は/); + }); +}); diff --git a/src/pages/_shindan.js b/src/pages/_shindan.js index 5afa926d..0d7a4a87 100644 --- a/src/pages/_shindan.js +++ b/src/pages/_shindan.js @@ -1015,14 +1015,46 @@ return Math.round(value).toLocaleString('ja-JP') + '人'; } - function renderShare(result, variant, gap) { - var resultUrl = canonicalResultUrl(result, gap); - var hook = variant.name + ':' + variant.catch; - var template = copy.share.textTemplate || '#AI働き方診断 私は【{タイプ名}】。{一言} {リンク}'; - var shareText = template + function jobShareFields(gap) { + if (!gap || !gap.jobId) return { jobTitle: null, score: null }; + var doc = searchById[String(gap.jobId)]; + if (!doc) return { jobTitle: null, score: null }; + var score = doc.ai_risk != null && !isNaN(doc.ai_risk) ? doc.ai_risk : null; + return { + jobTitle: doc.title_ja || null, + score: score + }; + } + + function fillShareTemplate(resultUrl, variant, gap, includeUrl) { + var fields = jobShareFields(gap); + var share = copy.share || {}; + var url = includeUrl === false ? '' : resultUrl; + if (fields.jobTitle && fields.score != null) { + var withJob = share.textTemplateWithJob || '#AI働き方診断 {職業}のAI影響度は{点数}。あなたの仕事は? {リンク}'; + return withJob + .replace(/\{職業\}/g, fields.jobTitle) + .replace(/\{点数\}/g, fields.score + '/10') + .replace(/\{リンク\}/g, url) + .replace(/\s+/g, ' ') + .trim(); + } + var template = share.textTemplate || '#AI働き方診断 私は【{タイプ名}】。{一言} {リンク}'; + return template .replace('{タイプ名}', variant.name) .replace('{一言}', variant.catch) - .replace('{リンク}', resultUrl); + .replace('{リンク}', url) + .replace(/\s+/g, ' ') + .trim(); + } + + function renderShare(result, variant, gap) { + var resultUrl = canonicalResultUrl(result, gap); + var fields = jobShareFields(gap); + var hook = fields.jobTitle && fields.score != null + ? fields.jobTitle + 'のAI影響度は' + fields.score + '/10。あなたの仕事は?' + : variant.name + ':' + variant.catch; + var shareText = fillShareTemplate(resultUrl, variant, gap, true); var xUrl = 'https://x.com/intent/post?text=' + encodeURIComponent(shareText); var lineUrl = 'https://line.me/R/msg/text/?' + encodeURIComponent(shareText); var imageUrl = ogImageUrl(result, gap); @@ -1058,12 +1090,12 @@ function nativeShare() { if (!currentResult || typeof navigator.share !== 'function') return; var variant = copy.variants[currentResult.code][currentResult.variantId]; - var text = (copy.share.textTemplate || '#AI働き方診断 私は【{タイプ名}】。{一言} {リンク}') - .replace('{タイプ名}', variant.name) - .replace('{一言}', variant.catch) - .replace(' {リンク}', '') - .replace('{リンク}', '') - .trim(); + var text = fillShareTemplate( + canonicalResultUrl(currentResult, currentGap), + variant, + currentGap, + false + ); navigator.share({ title: copy.labels.featureName, text: text, diff --git a/src/pages/me.astro b/src/pages/me.astro index f478df7f..59558cd0 100644 --- a/src/pages/me.astro +++ b/src/pages/me.astro @@ -29,7 +29,7 @@ import BaseLayout from '@/layouts/BaseLayout.astro'; import Footer from '@/components/Footer.astro'; import { OCCUPATION_COUNT } from '@/site/config'; import { NO_OCC_PATH } from '@/site/no-occ-path'; -import { QUESTIONS, VARIANT_BUCKETS_BY_FAMILY } from '@/site/worktype-copy'; +import { QUESTIONS, SHARE, VARIANT_BUCKETS_BY_FAMILY } from '@/site/worktype-copy'; const canonical = 'https://mirai-shigoto.com/me'; const title = '自分の現在地|全 39 ランキングでの位置を確認 - 未来の仕事'; @@ -52,6 +52,7 @@ function safeJsonForScript(value: unknown): string { const meQuizCopyJson = safeJsonForScript({ questions: QUESTIONS, variantBuckets: VARIANT_BUCKETS_BY_FAMILY, + share: SHARE, // /me screen 3 headings address この仕事 as the visitor's own job. // Separate from GAP in worktype-copy.ts, which still frames a looked-up occupation. gap: { @@ -213,6 +214,29 @@ const jsonLd = JSON.stringify({ font-weight: 600; color: var(--fg); font-variant-numeric: tabular-nums; } + .me-share { + margin: 16px 0 0; + display: flex; + flex-wrap: wrap; + gap: 10px; + align-items: center; + } + .me-share[hidden] { display: none; } + .me-share-open { + display: inline-flex; + align-items: center; + justify-content: center; + min-height: 44px; + padding: 0 18px; + border: 0; + border-radius: 999px; + background: var(--ink); + color: var(--paper); + font-size: 0.9rem; + font-weight: 800; + cursor: pointer; + } + .me-share-open:hover { filter: brightness(1.06); } .me-section { margin: 36px 0; } .me-section h2 { font-family: var(--font-serif); font-size: 1.25rem; font-weight: 600; @@ -380,6 +404,9 @@ const jsonLd = JSON.stringify({
年収
業種
+
diff --git a/src/site/shindan-share-html.test.ts b/src/site/shindan-share-html.test.ts index 5b6f18e8..c0bb79ed 100644 --- a/src/site/shindan-share-html.test.ts +++ b/src/site/shindan-share-html.test.ts @@ -30,6 +30,13 @@ const WORKTYPES = { }, }; +const DETAIL_133 = { + id: 133, + title: { ja: 'データ職業' }, + ai_risk: { score: 8.1 }, + stats: { workers: 1000, salary_man_yen: 500 }, +}; + const fetchFixture: typeof fetch = async (input) => { const url = new URL(String(input)); if (url.pathname === '/shindan') { @@ -38,6 +45,9 @@ const fetchFixture: typeof fetch = async (input) => { if (url.pathname === '/data.worktypes.json') { return Response.json(WORKTYPES); } + if (url.pathname === '/data.detail/0133.json') { + return Response.json(DETAIL_133); + } return new Response('not found', { status: 404 }); }; @@ -55,7 +65,8 @@ describe('crawler-rendered shindan share HTML', () => { assert.match(html, //); assert.ok(html.includes(``)); assert.ok(html.includes(``)); - assert.match(html, /自分 x 仕事のギャップ: 働き方を更新する余地があります/); + assert.match(html, /データ職業のAI影響度は8\.1\/10|AI働き方診断/); + assert.match(html, /データ職業のAI影響度は8\.1\/10。あなたの仕事は?/); assert.doesNotMatch(html, /gap=aligned/); }); diff --git a/src/site/shindan-share-html.ts b/src/site/shindan-share-html.ts index 0da1ea6a..845acb16 100644 --- a/src/site/shindan-share-html.ts +++ b/src/site/shindan-share-html.ts @@ -4,12 +4,21 @@ import { LABELS, VARIANTS, } from './worktype-copy.js'; +import { + formatShareMetaDescription, + formatShareMetaTitle, +} from './worktype-share.js'; import { buildShindanOgImageUrl, buildShindanResultUrl, type ShindanResultState, } from './shindan-result-state.js'; +export interface ShindanShareJobContext { + readonly title: string; + readonly score: number | null; +} + export interface ShindanShareMetadata { readonly title: string; readonly description: string; @@ -43,16 +52,27 @@ function replaceMeta( export function buildShindanShareMetadata( origin: string, state: ShindanResultState, + job?: ShindanShareJobContext | null, ): ShindanShareMetadata { const family = FAMILIES[state.family]; const variants = VARIANTS[state.family] as Readonly< Record >; const variant = variants[state.variant]!; - const context = state.gap ? ` ${LABELS.gap}: ${GAP[state.gap].label}。` : ''; + const gapLine = state.gap ? `${LABELS.gap}: ${GAP[state.gap].label}。` : ''; return { - title: `${variant.name}|${family.name} - ${LABELS.featureName}`, - description: `${variant.catch}${context}`, + title: formatShareMetaTitle({ + variantName: variant.name, + familyName: family.name, + jobTitle: job?.title, + score: job?.score, + }), + description: formatShareMetaDescription({ + catchLine: variant.catch, + gapLine, + jobTitle: job?.title, + score: job?.score, + }), url: buildShindanResultUrl(origin, state), image: buildShindanOgImageUrl(origin, state), }; diff --git a/src/site/worktype-copy.ts b/src/site/worktype-copy.ts index b24ac0da..0ab59d9a 100644 --- a/src/site/worktype-copy.ts +++ b/src/site/worktype-copy.ts @@ -454,11 +454,19 @@ export const GAP = { export const SHARE = { hashtag: '#AI働き方診断', + /** No occupation — identity only. Used when there is no number to share. */ textTemplate: '#AI働き方診断 私は【{タイプ名}】。{一言} {リンク}', + /** + * Occupation known — measurement-led (#237). The score is the hook; the + * type name stays on the page, not in the share unit. + */ + textTemplateWithJob: '#AI働き方診断 {職業}のAI影響度は{点数}。あなたの仕事は? {リンク}', challengeHooks: ['あなたの1枚もめくってみる?', '同僚と図鑑をめくり合おう'], + challengeHookWithJob: 'あなたの仕事は?', compareCta: '結果を比べる', copyLinkCta: 'リンクをコピー', xConsent: 'Xに投稿します', + meShareCta: 'この数字をシェア', } as const; export const LABELS = { diff --git a/src/site/worktype-share.test.ts b/src/site/worktype-share.test.ts new file mode 100644 index 00000000..0a334ef6 --- /dev/null +++ b/src/site/worktype-share.test.ts @@ -0,0 +1,84 @@ +import { describe, test } from 'node:test'; +import { strict as assert } from 'node:assert'; + +import { + formatShareHook, + formatShareMetaDescription, + formatShareMetaTitle, + formatShareScore, + formatShareText, + hasMeasurementShare, +} from './worktype-share.js'; + +describe('worktype share (#237)', () => { + test('identity-only when there is no occupation score', () => { + const text = formatShareText({ + url: 'https://mirai-shigoto.com/shindan?self=CPB', + variantName: 'ふれあい創造家', + catchLine: '人のそばで形にします。', + }); + assert.match(text, /私は【ふれあい創造家】/); + assert.doesNotMatch(text, /AI影響度/); + assert.equal(hasMeasurementShare(null, 7.2), false); + assert.equal(hasMeasurementShare('教員', null), false); + }); + + test('measurement-led when job title and score are present', () => { + const text = formatShareText({ + url: 'https://mirai-shigoto.com/me?id=133', + variantName: 'ふれあい創造家', + catchLine: '人のそばで形にします。', + jobTitle: 'データサイエンティスト', + score: 7.2, + }); + assert.equal( + text, + '#AI働き方診断 データサイエンティストのAI影響度は7.2/10。あなたの仕事は? https://mirai-shigoto.com/me?id=133', + ); + assert.doesNotMatch(text, /ふれあい創造家/); + assert.equal(formatShareScore(7.2), '7.2/10'); + assert.equal(hasMeasurementShare('データサイエンティスト', 7.2), true); + }); + + test('native share can omit the URL token', () => { + const text = formatShareText({ + url: 'https://mirai-shigoto.com/me?id=1', + variantName: 'x', + catchLine: 'y', + jobTitle: '教員', + score: 4, + includeUrl: false, + }); + assert.equal(text, '#AI働き方診断 教員のAI影響度は4/10。あなたの仕事は?'); + }); + + test('OG title and description follow the same hero', () => { + assert.equal( + formatShareMetaTitle({ + variantName: '段取りの世話役', + familyName: '段取りの世話役', + jobTitle: 'データ職業', + score: 8.1, + }), + 'データ職業のAI影響度は8.1/10|AI働き方診断', + ); + assert.match( + formatShareMetaDescription({ + catchLine: '一言', + jobTitle: 'データ職業', + score: 8.1, + gapLine: '自分 x 仕事のギャップ: 働き方を更新する余地があります。', + }), + /データ職業のAI影響度は8\.1\/10。あなたの仕事は?/, + ); + assert.equal( + formatShareHook({ + variantName: '段取りの世話役', + catchLine: '現場を回します。', + jobTitle: 'データ職業', + score: 8.1, + }), + 'データ職業のAI影響度は8.1/10。あなたの仕事は?', + ); + }); +}); diff --git a/src/site/worktype-share.ts b/src/site/worktype-share.ts new file mode 100644 index 00000000..aefdbed2 --- /dev/null +++ b/src/site/worktype-share.ts @@ -0,0 +1,92 @@ +/** + * Share text and OG metadata for the diagnostic (#237). + * + * Identity-only when there is no occupation (no number). Measurement-led + * when a job title and AI-impact score are present. + */ +import { LABELS, SHARE } from './worktype-copy.js'; + +export function formatShareScore(score: number | null | undefined): string | null { + if (score == null || typeof score !== 'number' || Number.isNaN(score)) return null; + return `${score}/10`; +} + +export function hasMeasurementShare( + jobTitle: string | null | undefined, + score: number | null | undefined, +): boolean { + return Boolean(jobTitle && formatShareScore(score)); +} + +export interface WorktypeShareInput { + readonly url: string; + readonly variantName: string; + readonly catchLine: string; + readonly jobTitle?: string | null; + readonly score?: number | null; + readonly includeUrl?: boolean; +} + +function applyTemplate( + template: string, + replacements: Readonly>, +): string { + let out = template; + for (const [token, value] of Object.entries(replacements)) { + out = out.split(token).join(value); + } + return out.replace(/\s+/g, ' ').trim(); +} + +export function formatShareText(input: WorktypeShareInput): string { + const includeUrl = input.includeUrl !== false; + const url = includeUrl ? input.url : ''; + const scoreLabel = formatShareScore(input.score); + if (input.jobTitle && scoreLabel) { + return applyTemplate(SHARE.textTemplateWithJob, { + '{職業}': input.jobTitle, + '{点数}': scoreLabel, + '{リンク}': url, + }); + } + return applyTemplate(SHARE.textTemplate, { + '{タイプ名}': input.variantName, + '{一言}': input.catchLine, + '{リンク}': url, + }); +} + +export function formatShareHook(input: Omit): string { + const scoreLabel = formatShareScore(input.score); + if (input.jobTitle && scoreLabel) { + return `${input.jobTitle}のAI影響度は${scoreLabel}。${SHARE.challengeHookWithJob}`; + } + return `${input.variantName}:${input.catchLine}`; +} + +export function formatShareMetaTitle(input: { + readonly variantName: string; + readonly familyName: string; + readonly jobTitle?: string | null; + readonly score?: number | null; +}): string { + const scoreLabel = formatShareScore(input.score); + if (input.jobTitle && scoreLabel) { + return `${input.jobTitle}のAI影響度は${scoreLabel}|${LABELS.featureName}`; + } + return `${input.variantName}|${input.familyName} - ${LABELS.featureName}`; +} + +export function formatShareMetaDescription(input: { + readonly catchLine: string; + readonly gapLine?: string; + readonly jobTitle?: string | null; + readonly score?: number | null; +}): string { + const scoreLabel = formatShareScore(input.score); + if (input.jobTitle && scoreLabel) { + const gap = input.gapLine ? ` ${input.gapLine}` : ''; + return `${input.jobTitle}のAI影響度は${scoreLabel}。${SHARE.challengeHookWithJob}${gap}`; + } + return `${input.catchLine}${input.gapLine ? ` ${input.gapLine}` : ''}`; +} diff --git a/tests/baseline/capture-meta.json b/tests/baseline/capture-meta.json index c0f27e85..5b714fe6 100644 --- a/tests/baseline/capture-meta.json +++ b/tests/baseline/capture-meta.json @@ -1,7 +1,7 @@ { - "capturedAt": "2026-08-20T04:10:18.560Z", - "gitCommit": "aa009bb78f119642a4491e82a43058df606c4c55", - "gitBranch": "feat/topnav-me", + "capturedAt": "2026-08-20T09:33:34.038Z", + "gitCommit": "5dc1a79666abc3e5079cb62bea16d4c1a1e27e4b", + "gitBranch": "feat/issue-237-share-number", "distFileCount": 834, "urlCount": 834, "dataFileCount": 612, diff --git a/tests/baseline/internal-links.jsonl b/tests/baseline/internal-links.jsonl index cce0c2cf..619d2f4a 100644 --- a/tests/baseline/internal-links.jsonl +++ b/tests/baseline/internal-links.jsonl @@ -675,7 +675,7 @@ {"url":"/life-balance/mental-health-friendly","internalHrefs":["#main-content","/","/127","/135","/136","/192","/205","/264","/274","/295","/298","/299","/300","/307","/319","/320","/321","/325","/426","/453","/454","/472","/520","/523","/533","/582","/583","/64","/85","/88","/93","/94","/abilities","/about","/aiadoption","/careers","/compare","/compliance","/data","/education","/employment-types","/entry-paths","/explore","/gyakuten","/interests","/knowledge","/licenses","/life-balance","/life-balance/child-care-balance","/life-balance/elderly-care-balance","/life-balance/health-friendly","/life-balance/hobby-balance","/life-balance/mental-health-friendly","/life-balance/senior-friendly","/map","/me","/methodology","/models","/privacy","/q","/rankings","/rankings/ai-safe-short-hours","/sectors","/shindan","/skills","/standard","/training","/values","/work-styles","/yearly"],"anchorIds":["content","cookieAccept","cookieBanner","cookieReject","main-content","mobBurger","mobDrawer","wrapper"]} {"url":"/life-balance/senior-friendly","internalHrefs":["#main-content","/","/119","/133","/155","/184","/186","/187","/188","/199","/241","/246","/31","/35","/401","/473","/475","/477","/478","/479","/480","/481","/487","/488","/490","/494","/496","/501","/525","/555","/97","/98","/abilities","/about","/aiadoption","/careers","/careers/60s-shinia","/compare","/compliance","/data","/education","/employment-types","/entry-paths","/explore","/gyakuten","/interests","/knowledge","/licenses","/life-balance","/life-balance/child-care-balance","/life-balance/elderly-care-balance","/life-balance/health-friendly","/life-balance/hobby-balance","/life-balance/mental-health-friendly","/life-balance/senior-friendly","/map","/me","/methodology","/models","/privacy","/q","/q/over-50-katsuyaku","/rankings","/rankings/aging-workforce","/sectors","/shindan","/skills","/standard","/training","/values","/work-styles","/yearly"],"anchorIds":["content","cookieAccept","cookieBanner","cookieReject","main-content","mobBurger","mobDrawer","wrapper"]} {"url":"/map","internalHrefs":["#main-content","/","/abilities","/about","/aiadoption","/careers","/compare","/compliance","/data","/education","/employment-types","/entry-paths","/explore","/gyakuten","/interests","/knowledge","/licenses","/life-balance","/map","/me","/methodology","/models","/privacy","/q","/rankings","/sectors","/shindan","/skills","/standard","/training","/values","/work-styles","/yearly"],"anchorIds":["cellTooltip","content","cookieAccept","cookieBanner","cookieReject","main-content","mapContent","mobBurger","mobDrawer","retryBtn","searchForm","searchInput","searchSuggest","sectorChips","sheet","sheetBackdrop","sheetClose","sheetCta","sheetHandle","sheetRank","sheetRisk","sheetSalary","sheetTitle","sheetWorkers","sortSelect","viewToggle"]} -{"url":"/me","internalHrefs":["#main-content","/","/abilities","/about","/aiadoption","/careers","/compare","/compliance","/data","/education","/employment-types","/entry-paths","/explore","/gyakuten","/interests","/knowledge","/licenses","/life-balance","/map","/me","/methodology","/models","/privacy","/q","/rankings","/sectors","/shindan","/skills","/standard","/training","/values","/work-styles","/yearly"],"anchorIds":["content","cookieAccept","cookieBanner","cookieReject","main-content","meAnnounce","meEmpty","meForm","meGap","meGapAction","meGapHeading","meGapMeter","meGapReading","meInput","meListbox","meNoOccEntry","meQuiz","meQuizCopy","meQuizCta","meQuizForm","meQuizHeading","meQuizOpen","meQuizSubmit","meRankList","meRankRest","meRankToggle","meRanksHead","meResults","meSimilar","meSimilarHead","meStatRisk","meStatSalary","meStatSector","meStatWorkers","meSummaryName","meSummarySector","mobBurger","mobDrawer"]} +{"url":"/me","internalHrefs":["#main-content","/","/abilities","/about","/aiadoption","/careers","/compare","/compliance","/data","/education","/employment-types","/entry-paths","/explore","/gyakuten","/interests","/knowledge","/licenses","/life-balance","/map","/me","/methodology","/models","/privacy","/q","/rankings","/sectors","/shindan","/skills","/standard","/training","/values","/work-styles","/yearly"],"anchorIds":["content","cookieAccept","cookieBanner","cookieReject","main-content","meAnnounce","meEmpty","meForm","meGap","meGapAction","meGapHeading","meGapMeter","meGapReading","meInput","meListbox","meNoOccEntry","meQuiz","meQuizCopy","meQuizCta","meQuizForm","meQuizHeading","meQuizOpen","meQuizSubmit","meRankList","meRankRest","meRankToggle","meRanksHead","meResults","meShare","meShareOpen","meSimilar","meSimilarHead","meStatRisk","meStatSalary","meStatSector","meStatWorkers","meSummaryName","meSummarySector","mobBurger","mobDrawer"]} {"url":"/methodology","internalHrefs":["#main-content","/","/abilities","/about","/aiadoption","/careers","/compare","/compliance","/data","/education","/employment-types","/entry-paths","/explore","/gyakuten","/interests","/knowledge","/licenses","/life-balance","/map","/me","/methodology","/models","/privacy","/q","/rankings","/sectors","/shindan","/skills","/standard","/training","/values","/work-styles","/yearly"],"anchorIds":["content","cookieAccept","cookieBanner","cookieReject","main-content","mobBurger","mobDrawer","wrapper"]} {"url":"/models","internalHrefs":["#main-content","/","/10","/106","/111","/114","/170","/19","/29","/55","/abilities","/about","/aiadoption","/careers","/compare","/compliance","/data","/education","/employment-types","/entry-paths","/explore","/gyakuten","/interests","/knowledge","/licenses","/life-balance","/map","/me","/methodology","/models","/models/fable-5@2026-06-13","/models/gpt-5.6-sol@2026-07-12","/models/opus-4-7@2026-04-25","/models/opus-4-8@2026-05-30","/models/opus-5@2026-07-26","/privacy","/q","/rankings","/sectors","/shindan","/skills","/standard","/training","/values","/work-styles","/yearly"],"anchorIds":["content","cookieAccept","cookieBanner","cookieReject","current-model-title","main-content","mobBurger","mobDrawer","models-contrast","models-cta","models-projection","models-roster","models-stories","wrapper"]} {"url":"/models/fable-5@2026-06-13","internalHrefs":["#main-content","/","/1","/11","/175","/239","/3","/30","/307","/32","/33","/357","/398","/413","/424","/428","/429","/435","/44","/441","/443","/455","/47","/50","/540","/64","/74","/8","/87","/9","/abilities","/about","/aiadoption","/careers","/compare","/compliance","/data","/education","/employment-types","/entry-paths","/explore","/gyakuten","/interests","/knowledge","/licenses","/life-balance","/map","/me","/methodology","/models","/models/fable-5@2026-06-13","/models/gpt-5.6-sol@2026-07-12","/models/opus-4-8@2026-05-30","/privacy","/q","/rankings","/sectors","/shindan","/skills","/standard","/training","/values","/work-styles","/yearly"],"anchorIds":["content","cookieAccept","cookieBanner","cookieReject","distribution","drift","extremes","main-content","mobBurger","mobDrawer","model-page-payload","neighbours","wrapper"]} diff --git a/vercel.json b/vercel.json index e8d1c2c8..4ede58b9 100644 --- a/vercel.json +++ b/vercel.json @@ -16,7 +16,7 @@ { "key": "X-Frame-Options", "value": "DENY" }, { "key": "Cross-Origin-Opener-Policy", "value": "same-origin" }, { "key": "Strict-Transport-Security", "value": "max-age=31536000; includeSubDomains; preload" }, - { "key": "Content-Security-Policy", "value": "default-src 'self'; script-src 'self' https://static.cloudflareinsights.com https://*.googletagmanager.com https://www.google-analytics.com https://va.vercel-scripts.com https://static.ads-twitter.com https://connect.facebook.net 'sha256-+5bdLHzj8GiTPeItmNhH70xQPa7yclrHL56ESG2Vqjs=' 'sha256-1rv/mZufcZoaPwpsbw987HwE/M+WuYKzHXuF3BCvzOo=' 'sha256-2ap6HbmAAZ4jml1AfuvXiZFn94p0hyE+ePVa7+OrkrY=' 'sha256-685wL/dHCdikCfQrWJc8/3h3zn+7ugScWhAVQZcHVr8=' 'sha256-80on6q+4N8b4tdPifTn79iWlzXfYoRuhIjokIq9QGw4=' 'sha256-8pJ3laaECnNFCT3EG1ABP5Ie2vlMu5JHFMeCxeofC0w=' 'sha256-FVDsBMyIkFkMuh4pcxmfkeKhemFWJy8Wf0rRwb26HrQ=' 'sha256-L4QbaDe3zN+KRmmJzQvuDGTdSssRmLG9ZapL7Ibsc0k=' 'sha256-OBp8uuNIQSHOHcXhZHIOZnqEuu8ufoiOqiPWL8BHewk=' 'sha256-UoO7Y/sJrjo2L/d+JjLhS40cLwmEiZtcgOPRajhQf0A=' 'sha256-Wh9zciDqykJNkHuKRXmqAAo0ZrPEXW5e6Q/KUKpG8nA=' 'sha256-jRrnRv5/fQxjvRU3yt5Jkof1D1RxX5hO6GplM9EraZ4=' 'sha256-lHt/NrC3+OZ68yhsPFaSNgnPtjstK0aG1YjOwtj4ecs=' 'sha256-mEjXucpUExIz3nx3AizABlBEO3RXLDXVXIkrpe7XvPk=' 'sha256-ngMlJ9AnKKGzCZJpdTu/9u1ouZJdnE5UH2bibiV9jXk=' 'sha256-nteMg/XPiAM10FHVDeo3XNBvVT7qg8ELII75+qi17vc=' 'sha256-rMk6BYbivudkhnerx/Rk2lI++sOY2uBxHPARDHh/Tpk=' 'sha256-rtTYLg1aJvZmwCs10Oh7gxsU9JzW4fzO/kfUuQU6RE8=' 'sha256-vLf/TS2H7w0qx7j66lohFNPTEgHM/GtEUYO0CGsSbk0=' 'sha256-x4JMRHHxuJFMff+ZyUM1lgOELW03/4yhCe6wetFlMH0=' 'sha256-xD6hZa6sAXFt/YW5BTXyUDDy/VTct3vVyIb47bVSYwA=' 'sha256-ydj6+dSc8i8BEKvEm189N5kaob/4699a/kYnrtbdEoI='; style-src 'self' 'unsafe-inline'; font-src 'self'; img-src 'self' data: blob: https:; connect-src 'self' https://cloudflareinsights.com https://*.cloudflareinsights.com https://*.google-analytics.com https://analytics.google.com https://www.google.com https://www.googletagmanager.com https://googleads.g.doubleclick.net https://www.googleadservices.com https://vitals.vercel-insights.com https://analytics.twitter.com https://t.co https://www.facebook.com https://connect.facebook.net; frame-src 'none'; frame-ancestors 'none'; object-src 'none'; base-uri 'self'; form-action 'self'; upgrade-insecure-requests" } + { "key": "Content-Security-Policy", "value": "default-src 'self'; script-src 'self' https://static.cloudflareinsights.com https://*.googletagmanager.com https://www.google-analytics.com https://va.vercel-scripts.com https://static.ads-twitter.com https://connect.facebook.net 'sha256-+5bdLHzj8GiTPeItmNhH70xQPa7yclrHL56ESG2Vqjs=' 'sha256-1rv/mZufcZoaPwpsbw987HwE/M+WuYKzHXuF3BCvzOo=' 'sha256-2ap6HbmAAZ4jml1AfuvXiZFn94p0hyE+ePVa7+OrkrY=' 'sha256-685wL/dHCdikCfQrWJc8/3h3zn+7ugScWhAVQZcHVr8=' 'sha256-80on6q+4N8b4tdPifTn79iWlzXfYoRuhIjokIq9QGw4=' 'sha256-8pJ3laaECnNFCT3EG1ABP5Ie2vlMu5JHFMeCxeofC0w=' 'sha256-Cjl8ygea1H/oGRAgQ4uThbMdSOdAUPc7/Wz/isIyYDU=' 'sha256-EGZLYoBkSY/cGkL64HRbxX0oayerfoFvX3MjKUVA8O0=' 'sha256-FVDsBMyIkFkMuh4pcxmfkeKhemFWJy8Wf0rRwb26HrQ=' 'sha256-L4QbaDe3zN+KRmmJzQvuDGTdSssRmLG9ZapL7Ibsc0k=' 'sha256-OBp8uuNIQSHOHcXhZHIOZnqEuu8ufoiOqiPWL8BHewk=' 'sha256-UoO7Y/sJrjo2L/d+JjLhS40cLwmEiZtcgOPRajhQf0A=' 'sha256-jRrnRv5/fQxjvRU3yt5Jkof1D1RxX5hO6GplM9EraZ4=' 'sha256-lHt/NrC3+OZ68yhsPFaSNgnPtjstK0aG1YjOwtj4ecs=' 'sha256-mEjXucpUExIz3nx3AizABlBEO3RXLDXVXIkrpe7XvPk=' 'sha256-nteMg/XPiAM10FHVDeo3XNBvVT7qg8ELII75+qi17vc=' 'sha256-rMk6BYbivudkhnerx/Rk2lI++sOY2uBxHPARDHh/Tpk=' 'sha256-rtTYLg1aJvZmwCs10Oh7gxsU9JzW4fzO/kfUuQU6RE8=' 'sha256-vLf/TS2H7w0qx7j66lohFNPTEgHM/GtEUYO0CGsSbk0=' 'sha256-x4JMRHHxuJFMff+ZyUM1lgOELW03/4yhCe6wetFlMH0=' 'sha256-xD6hZa6sAXFt/YW5BTXyUDDy/VTct3vVyIb47bVSYwA=' 'sha256-ydj6+dSc8i8BEKvEm189N5kaob/4699a/kYnrtbdEoI='; style-src 'self' 'unsafe-inline'; font-src 'self'; img-src 'self' data: blob: https:; connect-src 'self' https://cloudflareinsights.com https://*.cloudflareinsights.com https://*.google-analytics.com https://analytics.google.com https://www.google.com https://www.googletagmanager.com https://googleads.g.doubleclick.net https://www.googleadservices.com https://vitals.vercel-insights.com https://analytics.twitter.com https://t.co https://www.facebook.com https://connect.facebook.net; frame-src 'none'; frame-ancestors 'none'; object-src 'none'; base-uri 'self'; form-action 'self'; upgrade-insecure-requests" } ] }, {