diff --git a/__mocks__/fixtures/video-detail.html b/__mocks__/fixtures/video-detail.html index 2b67914..a12b78a 100644 --- a/__mocks__/fixtures/video-detail.html +++ b/__mocks__/fixtures/video-detail.html @@ -16,8 +16,28 @@
TestUser
TestUser +
+
+ + + +
Starfire
+ Starfire +
+ +
+ + + +
OpenNSFW (VA)
+ OpenNSFW (VA) +
+ +
+ +
1
+
Jackerman
-Starfire test tag animation Test Category diff --git a/src/api/common/r34.test.ts b/src/api/common/r34.test.ts index 0439879..1d6b586 100644 --- a/src/api/common/r34.test.ts +++ b/src/api/common/r34.test.ts @@ -65,7 +65,10 @@ describe('r34Client', () => { expect(result.uploader).toBe('TestUser'); expect(result.uploaderMemberId).toBe('99999'); - expect(result.artist).toBe('Starfire'); + expect(result.artists).toEqual([ + { name: 'Starfire', slug: 'starfire' }, + { name: 'OpenNSFW (VA)', slug: 'opennsfw' }, + ]); expect(result.formats.length).toBeGreaterThan(0); }); }); diff --git a/src/app/post/[id].tsx b/src/app/post/[id].tsx index f073146..4b9fe86 100644 --- a/src/app/post/[id].tsx +++ b/src/app/post/[id].tsx @@ -9,6 +9,7 @@ import { useVideoDetail } from '@/api/video-queries'; import { ActivityIndicator, Button, FocusAwareStatusBar, Text } from '@/components/ui'; import { useVideoDownload } from '@/lib/hooks'; import { useTranslate } from '@/lib/i18n'; +import { artistChipLabel } from '@/lib/r34/artists'; import { toOfflineDetail } from '@/lib/r34/offline-detail'; import type { VideoDetail, VideoFormat } from '@/lib/r34/types'; import { baseIdOf, useDownloadedStore } from '@/lib/stores/downloaded-store'; @@ -312,23 +313,27 @@ export default function Post(): React.ReactElement | null { - {data.artist ? ( + {(data.artists ?? []).map((artist) => ( - Artist: {data.artist} + {artistChipLabel(artist.name)} - ) : null} + ))} {data.uploader && data.uploaderMemberId ? ( { + it('uses the generic Artist prefix for plain names', () => { + expect(artistChipLabel('Jackerman')).toBe('Artist: Jackerman'); + expect(artistChipLabel('General Butch')).toBe('Artist: General Butch'); + }); + + it('promotes known role suffixes to the prefix and strips them', () => { + expect(artistChipLabel('OpenNSFW (VA)')).toBe('VA: OpenNSFW'); + expect(artistChipLabel('MizzPeachy (VA)')).toBe('VA: MizzPeachy'); + expect(artistChipLabel('HentAudio (Audio)')).toBe('Audio: HentAudio'); + expect(artistChipLabel('Someone (Artist)')).toBe('Artist: Someone'); + }); + + it('keeps unknown parentheticals as part of the name', () => { + // Only VA/Audio/Artist are known roles — anything else stays verbatim so + // meaningful suffixes aren't silently dropped. + expect(artistChipLabel('Studio X (official)')).toBe('Artist: Studio X (official)'); + }); + + it('handles degenerate inputs without matching', () => { + expect(artistChipLabel('(VA)')).toBe('Artist: (VA)'); + expect(artistChipLabel('')).toBe('Artist: '); + }); +}); diff --git a/src/lib/r34/artists.ts b/src/lib/r34/artists.ts new file mode 100644 index 0000000..14d5eff --- /dev/null +++ b/src/lib/r34/artists.ts @@ -0,0 +1,18 @@ +/** + * Chip label for a site model (artist). The site appends a role to some model + * display names in parentheses — "(VA)" for voice actors, "(Audio)" for audio + * work — e.g. "OpenNSFW (VA)". Promote a known role to the label prefix and + * strip it from the name: "VA: OpenNSFW". Models without a known role keep + * the generic "Artist: Name" (with their name untouched, parenthetical + * included — it may carry meaning we don't want to silently drop). + */ +const ROLE_PREFIXES = new Set(['VA', 'Audio', 'Artist']); + +export function artistChipLabel(name: string): string { + const match = name.match(/^(.+?)\s*\(([^()]+)\)$/); + const role = match?.[2].trim(); + if (match && role && ROLE_PREFIXES.has(role)) { + return `${role}: ${match[1].trim()}`; + } + return `Artist: ${name}`; +} diff --git a/src/lib/r34/offline-detail.test.ts b/src/lib/r34/offline-detail.test.ts index 47c16a6..148f382 100644 --- a/src/lib/r34/offline-detail.test.ts +++ b/src/lib/r34/offline-detail.test.ts @@ -37,7 +37,8 @@ describe('toOfflineDetail', () => { expect(detail.tags).toEqual([]); expect(detail.categories).toEqual([]); - expect(detail.artist).toBeUndefined(); + expect(detail.artists).toEqual([]); + expect(detail.uploader).toBeUndefined(); expect(detail.description).toBeUndefined(); }); }); diff --git a/src/lib/r34/offline-detail.ts b/src/lib/r34/offline-detail.ts index 8cd2d65..3a89043 100644 --- a/src/lib/r34/offline-detail.ts +++ b/src/lib/r34/offline-detail.ts @@ -5,7 +5,7 @@ import type { VideoDetail } from './types'; /** * Build a minimal VideoDetail from a downloaded item so the post page can * render (and play the local file) with no network. Rich metadata - * (tags/categories/artist) is unavailable offline by design. + * (tags/categories/artists) is unavailable offline by design. */ export function toOfflineDetail(meta: DownloadMetadata, id: string): VideoDetail { return { @@ -19,5 +19,6 @@ export function toOfflineDetail(meta: DownloadMetadata, id: string): VideoDetail formats: [{ url: meta.uri, quality: meta.quality, ext: 'mp4' }], tags: [], categories: [], + artists: [], }; } diff --git a/src/lib/r34/scraper.test.ts b/src/lib/r34/scraper.test.ts index 49ad20e..f8a2532 100644 --- a/src/lib/r34/scraper.test.ts +++ b/src/lib/r34/scraper.test.ts @@ -98,11 +98,46 @@ describe('scraper', () => { expect(detail.tags).toContainEqual({ id: '67890', name: 'animation' }); expect(detail.uploader).toBe('TestUser'); expect(detail.uploaderMemberId).toBe('99999'); - expect(detail.artist).toBe('Starfire'); + expect(detail.artists).toEqual([ + { name: 'Starfire', slug: 'starfire' }, + { name: 'OpenNSFW (VA)', slug: 'opennsfw' }, + ]); expect(detail.categories).toContain('Test Category'); expect(detail.description).toBe('A test video description for Starfire.'); }); + it('does not treat sidebar "Top Artists" links as video artists', () => { + const html = loadFixture('video-detail.html'); + const detail = parseVideoDetail(html, '4406161', 'starfire-quick-hot-encounter'); + + // The fixture contains a sidebar-style link (class="item", no btn_link) + // to /models/jackerman/ that must not bleed into the artist list. + expect(detail.artists.some((a) => a.slug === 'jackerman')).toBe(false); + }); + + it('navigates artists by their real slug, not a name-derived one', () => { + const html = loadFixture('video-detail.html'); + const detail = parseVideoDetail(html, '4406161', 'starfire-quick-hot-encounter'); + + // "OpenNSFW (VA)" used to become the slug "opennsfw-(va)" (a 404 on the + // site); the real slug from the href is "opennsfw". + const derived = detail.artists[1].name.toLowerCase().replace(/\s+/g, '-'); + expect(derived).not.toBe(detail.artists[1].slug); + expect(detail.artists[1].slug).toBe('opennsfw'); + }); + + it('falls back to the anchor text when the uploader has no avatar', () => { + const html = ` + Minimal - Rule34Video +
Uploaded by
+ NoAvatarUser + `; + const detail = parseVideoDetail(html, '1', 'x'); + + expect(detail.uploaderMemberId).toBe('4242'); + expect(detail.uploader).toBe('NoAvatarUser'); + }); + it('does not expose removed uploaderUrl field', () => { const html = loadFixture('video-detail.html'); const detail = parseVideoDetail(html, '4406161', 'starfire-quick-hot-encounter') as Record< @@ -122,6 +157,9 @@ describe('scraper', () => { expect(detail.title).toBe('Minimal'); expect(detail.formats).toEqual([]); expect(detail.tags).toEqual([]); + expect(detail.artists).toEqual([]); + expect(detail.uploader).toBeUndefined(); + expect(detail.uploaderMemberId).toBeUndefined(); }); }); diff --git a/src/lib/r34/scraper.ts b/src/lib/r34/scraper.ts index 21adbff..cb9a4aa 100644 --- a/src/lib/r34/scraper.ts +++ b/src/lib/r34/scraper.ts @@ -159,19 +159,43 @@ export function parseVideoDetail(html: string, videoId: string, slug: string): V categories.push(match[1].trim()); } - // Extract uploader (a site member). Capture the member id from the profile - // link and the name from the avatar's alt attribute. + // Extract the uploader (a site member). Anchor on the "Uploaded by" label, + // then bind the name to the member anchor's own inner HTML — the avatar's + // alt text first, plain text (tags stripped) as a fallback for members + // without an avatar. This can't bleed into unrelated elements further down + // the page the way a lazy document-wide scan could. const uploaderMatch = html.match( - /Uploaded by<\/div>\s*]+href="(?https:\/\/rule34video\.com\/members\/(?\d+)\/)"[\s\S]*?alt="(?[^"]*)"/i + /Uploaded by<\/div>\s*]*href="https:\/\/rule34video\.com\/members\/(\d+)\/"[^>]*>([\s\S]*?)<\/a>/i ); - const uploader = uploaderMatch?.groups?.name?.trim(); - const uploaderMemberId = uploaderMatch?.groups?.id; - - // Extract artist - const artistMatch = html.match( - /]+class="item btn_link"[^>]+href="(?[^"]+)"[^>]*>\s*\s*(?[^<]+)\s*<\/span>\s*<\/a>/i - ); - const artist = artistMatch?.groups?.name?.trim(); + const uploaderMemberId = uploaderMatch?.[1]; + const uploaderInner = uploaderMatch?.[2] ?? ''; + const uploader = + uploaderInner.match(/alt="([^"]*)"/)?.[1]?.trim() || + uploaderInner + .replace(/<[^>]*>/g, ' ') + .replace(/\s+/g, ' ') + .trim() || + undefined; + + // Extract artists (site "models"). Each artist is a pill anchor (class + // contains "item btn_link") linking to /models/{slug}/ — the sidebar's "Top + // Artists" links (class="item", no btn_link) must not match. The slug comes + // from the href itself: deriving it from the display name breaks for names + // like "OpenNSFW (VA)", whose real site slug is "opennsfw". + // NOTE: positional capture groups (match[1..n]) — Hermes' matchAll does not + // populate `.groups` for named captures the way V8 does. + const artists: { name: string; slug: string }[] = []; + const artistRegex = + /]*class="[^"]*\bitem btn_link\b[^"]*"[^>]*href="https:\/\/rule34video\.com\/models\/([^\/"]+)\/"[^>]*>([\s\S]*?)<\/a>/g; + for (const match of html.matchAll(artistRegex)) { + const slug = match[1]; + if (artists.some((a) => a.slug === slug)) continue; + const name = + match[2].match(/\s*([^<]+?)\s*<\/span>/)?.[1]?.trim() || + match[2].match(/alt="([^"]*)"/)?.[1]?.trim() || + slug; + artists.push({ name, slug }); + } // Extract description const descriptionMatch = html.match(/