Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
4 changes: 3 additions & 1 deletion ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand All @@ -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)

Expand Down
38 changes: 36 additions & 2 deletions api/shindan-share.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,17 @@
import { trustedFetchOrigin, WorktypesProjectionSchema } from '../src/lib/og-helpers.js';
import {
DetailRecordSchema,
padId,
trustedFetchOrigin,
WorktypesProjectionSchema,
} from '../src/lib/og-helpers.js';
import {
addShindanOccupationContext,
parseShindanBaseState,
} from '../src/site/shindan-result-state.js';
import {
buildShindanShareMetadata,
renderShindanShareHtml,
type ShindanShareJobContext,
} from '../src/site/shindan-share-html.js';

export const config = {
Expand Down Expand Up @@ -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,
Expand All @@ -70,6 +78,32 @@ export async function renderShindanShareResponse(
});
}

async function fetchShareJobContext(
origin: string,
jobId: string,
fetchImpl: FetchLike,
): Promise<ShindanShareJobContext | null> {
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<Response> {
return renderShindanShareResponse(request);
}
17 changes: 11 additions & 6 deletions docs/ME_CONSOLIDATION.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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`
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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.
10 changes: 7 additions & 3 deletions docs/WORKTYPE_VIRALITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 7 additions & 0 deletions middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ import {
classifyClientKind,
deliveryIdentity,
isBotUserAgent,
isShareUnfurlerUserAgent,
shouldSendMpHit,
buildMpPayload,
classifyGeoReferral,
Expand All @@ -64,6 +65,7 @@ import {
} from './src/lib/middleware-helpers.js';
import { fetchWithTimeout } from './src/lib/http-client.js';
import {
meOccupationOgRewriteTarget,
noOccAliasRedirectTarget,
shindanOccupationRedirectTarget,
shindanShareRewriteTarget,
Expand Down Expand Up @@ -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();

Expand Down
11 changes: 11 additions & 0 deletions src/lib/middleware-helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down
3 changes: 3 additions & 0 deletions src/lib/og-renderers/worktype.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,13 @@ test('worktype OG context uses recomputed job gap copy', () => {
title: 'データ職業',
worktypeCode: 'CDB',
worktypeName: 'ものづくり設計家',
score: 8.1,
});

assert.equal(gap, 'hidden_risk');
assert.equal(buildWorktypeFeatureLabel('RPK'), 'AI働き方診断 / 段取りの世話役');
assert.match(context, /データ職業 \/ ものづくり設計家/);
assert.match(context, /働き方を更新する余地があります/);
});


85 changes: 77 additions & 8 deletions src/lib/og-renderers/worktype.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import { createElement as h } from 'react';
import type { ReactElement } from 'react';
import {
DetailRecordSchema,
RISK_COLORS,
WorktypesProjectionSchema,
loadGoogleFont,
padId,
Expand Down Expand Up @@ -68,6 +69,7 @@ export interface WorktypeJobContext {
readonly title: string;
readonly worktypeCode: FamilyCode;
readonly worktypeName?: string;
readonly score: number | null;
}

export async function renderWorktypeOgCard(
Expand All @@ -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,
Expand All @@ -115,6 +123,8 @@ export async function renderWorktypeOgCard(
DISCLAIMER,
FOOTER_LEFT,
SHARE.hashtag,
SHARE.challengeHookWithJob,
scoreLabel ? `${scoreLabel} / 10` : '',
FRAME_SUBSET,
].join(' ');

Expand All @@ -135,6 +145,8 @@ export async function renderWorktypeOgCard(
sharePrompt,
variantCopy,
visual,
scoreLabel,
scoreColor,
})
: renderWideCard({
accent,
Expand All @@ -144,6 +156,8 @@ export async function renderWorktypeOgCard(
sharePrompt,
variantCopy,
visual,
scoreLabel,
scoreColor,
}),
{
width: isSquare ? 1080 : 1200,
Expand All @@ -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 {
Expand All @@ -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',
{
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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',
{
Expand All @@ -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',
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -466,6 +534,7 @@ async function fetchJobContext(
title,
worktypeCode: worktypeRecord.code,
worktypeName: FAMILIES[worktypeRecord.code].name,
score: detailParsed.data.ai_risk?.score ?? null,
};
}

Expand Down
Loading
Loading