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
22 changes: 21 additions & 1 deletion __mocks__/fixtures/video-detail.html
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,28 @@
<a class="item btn_link video_meta_pill" href="https://rule34video.com/members/99999/">
<div class="wrap_image"><img src="https://rule34video.com/contents/avatars/0/99999.jpg" alt="TestUser"/></div>
TestUser
<div class="verified-status"><svg class="custom-svg custom-verified"><use xlink:href="#custom-verified"></use></svg></div>
</a>
<span class="tag_vote_chip video_meta_vote_chip js-video-vote-chip" data-video-id="4406161" data-item-type="model" data-item-id="1" data-status="normal">
<span class="tag_vote_progress" aria-hidden="true"></span>
<a class="item btn_link video_meta_pill" href="https://rule34video.com/models/starfire/">
<div class="wrap_image"><img src="https://rule34video.com/contents/models/1/s1.jpg" alt="Starfire"/></div>
<span class="name">Starfire</span>
</a>
<span class="tag_vote_controls"></span>
</span>
<span class="tag_vote_chip video_meta_vote_chip js-video-vote-chip" data-video-id="4406161" data-item-type="model" data-item-id="2" data-status="normal">
<span class="tag_vote_progress" aria-hidden="true"></span>
<a class="item btn_link video_meta_pill" href="https://rule34video.com/models/opennsfw/">
<div class="wrap_image"><img src="https://rule34video.com/contents/models/2/s1.png" alt="OpenNSFW (VA)"/></div>
<span class="name">OpenNSFW (VA)</span>
</a>
<span class="tag_vote_controls"></span>
</span>
<a href="https://rule34video.com/models/jackerman/" class="item">
<div class="position">1</div>
<div class="name">Jackerman</div>
</a>
<a class="item btn_link" href="https://rule34video.com/models/starfire/"><span class="name">Starfire</span></a>
<a class="tag_item" href="https://rule34video.com/tags/12345/">test tag</a>
<a class="tag_item" href="https://rule34video.com/tags/67890/">animation</a>
<a href="https://rule34video.com/categories/test-category/">Test Category</a>
Expand Down
5 changes: 4 additions & 1 deletion src/api/common/r34.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
Expand Down
13 changes: 9 additions & 4 deletions src/app/post/[id].tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -312,23 +313,27 @@ export default function Post(): React.ReactElement | null {
</View>

<View className="mb-6 flex-row flex-wrap items-center gap-4">
{data.artist ? (
{(data.artists ?? []).map((artist) => (
<Link
key={artist.slug}
href={{
pathname: '/model/[slug]',
params: { slug: data.artist.toLowerCase().replace(/\s+/g, '-') },
// Navigate with the real site slug from the scraper —
// deriving it from the display name 404s for names like
// "OpenNSFW (VA)" (actual slug: "opennsfw").
params: { slug: artist.slug },
}}
asChild
>
<TouchableOpacity>
<View className="flex-row items-center rounded-full bg-primary-100 px-3 py-1.5 dark:bg-primary-900/30">
<Text className="text-sm font-semibold text-primary-900 dark:text-primary-100">
Artist: {data.artist}
{artistChipLabel(artist.name)}
</Text>
</View>
</TouchableOpacity>
</Link>
) : null}
))}

{data.uploader && data.uploaderMemberId ? (
<Link
Expand Down
26 changes: 26 additions & 0 deletions src/lib/r34/artists.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { artistChipLabel } from './artists';

describe('artistChipLabel', () => {
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: ');
});
});
18 changes: 18 additions & 0 deletions src/lib/r34/artists.ts
Original file line number Diff line number Diff line change
@@ -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}`;
}
3 changes: 2 additions & 1 deletion src/lib/r34/offline-detail.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
});
3 changes: 2 additions & 1 deletion src/lib/r34/offline-detail.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -19,5 +19,6 @@ export function toOfflineDetail(meta: DownloadMetadata, id: string): VideoDetail
formats: [{ url: meta.uri, quality: meta.quality, ext: 'mp4' }],
tags: [],
categories: [],
artists: [],
};
}
40 changes: 39 additions & 1 deletion src/lib/r34/scraper.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = `
<html><body><title>Minimal - Rule34Video</title>
<div>Uploaded by</div>
<a class="item btn_link video_meta_pill" href="https://rule34video.com/members/4242/">NoAvatarUser</a>
</body></html>`;
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<
Expand All @@ -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();
});
});

Expand Down
48 changes: 36 additions & 12 deletions src/lib/r34/scraper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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*<a[^>]+href="(?<url>https:\/\/rule34video\.com\/members\/(?<id>\d+)\/)"[\s\S]*?alt="(?<name>[^"]*)"/i
/Uploaded by<\/div>\s*<a[^>]*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(
/<a[^>]+class="item btn_link"[^>]+href="(?<url>[^"]+)"[^>]*>\s*<span class="name">\s*(?<name>[^<]+)\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 =
/<a[^>]*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(/<span class="name">\s*([^<]+?)\s*<\/span>/)?.[1]?.trim() ||
match[2].match(/alt="([^"]*)"/)?.[1]?.trim() ||
slug;
artists.push({ name, slug });
}

// Extract description
const descriptionMatch = html.match(/<script type="application\/ld\+json">([\s\S]*?)<\/script>/);
Expand All @@ -197,7 +221,7 @@ export function parseVideoDetail(html: string, videoId: string, slug: string): V
formats,
tags,
categories,
artist,
artists,
uploader,
uploaderMemberId,
description,
Expand Down
9 changes: 8 additions & 1 deletion src/lib/r34/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,11 +20,18 @@ export type VideoTag = {
name: string;
};

/** A site "model" (artist) with its real URL slug — names don't map to slugs. */
export type VideoArtist = {
name: string;
slug: string;
};

export type VideoDetail = VideoListItem & {
formats: VideoFormat[];
tags: VideoTag[];
categories: string[];
artist?: string;
/** Artists (site models) — a video can have several. */
artists: VideoArtist[];
uploader?: string;
uploaderMemberId?: string;
description?: string;
Expand Down