fix(hqfap): resolve on the device (site blocks the server IP)
Some checks are pending
Backend tests / test (push) Waiting to run

hqfap.com's Cloudflare blocks the VPS (Hetzner) IP — the server times out fetching
the page (0 bytes), so backend resolve returns None (503) or a stale/IP-bound URL that
fails on the phone (35 player_errors / 3 days, the top error source). But the page loads
fine from residential IPs (200, 0.7s) and its okcdn/vstor/cdnde mp4 is portable cross-IP
(srcIp not enforced, verified 206 from residential).

Fix: phone-side resolve, same pattern as sxyprn/eporner/fpoxxx. New hqfapResolver.ts
(fetch page on device -> JSON-LD contentUrl -> direct mp4, mirror of hqfap.py) wired into
the proactive phone-resolve chain in SceneDetail and the on-error re-resolve in
PlayerScreen; hqfapcom added to PHONE_RESOLVE_ORIGINS. Backend hqfap.extract stays as a
last-resort fallback.

Note: sxyprn/eporner were checked and are NOT broken (95-96% play success; their "gone"
events are real deletions), so no change there.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
goon-foss 2026-07-14 22:20:43 +03:00
parent 7e2807b42e
commit 92adaccf1b
4 changed files with 66 additions and 2 deletions

View file

@ -16,6 +16,13 @@ export type ChangelogEntry = {
};
export const CHANGELOG: ChangelogEntry[] = [
{
id: '2026-07-14',
date: 'July 2026',
items: [
'hqfap: videos play reliably again. The site started blocking our server, so the app now resolves hqfap links directly on your device, fixing the cases that only showed ads or failed to start.',
],
},
{
id: '2026-07-11',
date: 'July 2026',

View file

@ -0,0 +1,52 @@
/**
* Mobile-side hqfap.com resolver.
*
* hqfap.com Cloudflare blokuje IP VPS (Hetzner: timeout, 0 bajtów) ale strona odpowiada
* 200 z residential IP (audit 2026-07-14: local 200/0.7s, VPS + Bright Data proxy oba
* timeout). Backend resolve więc pada (None 503) albo oddaje URL zbindowany do kontekstu
* VPS player_error. Strumień (okcdn.ru / vstor.top / cdnde, direct mp4 z JSON-LD
* contentUrl) jest PORTABLE cross-IP (srcIp NIEegzekwowany, zweryfikowane 206 z residential).
*
* Fix: telefon sam pobiera stronę (residential IP) JSON-LD contentUrl gra direct, zero
* VPS. Mirror app/extractors/tubes/hqfap.py. Zwraca [] gdy brak (caller spada na backend).
*/
import type { StreamLink } from '../types';
const UA =
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36';
const _CONTENT_URL_RE = /"contentUrl"\s*:\s*"([^"]+)"/;
const _QUALITY_RE = /_(\d{3,4})p\.mp4/i;
/** True dla page_url hqfap (origin tube:hqfapcom). */
export function isHqfapUrl(url: string | null | undefined): boolean {
return !!url && /(?:^|\/\/|\.)hqfap\.com\//i.test(url);
}
export async function resolveHqfapPage(pageUrl: string): Promise<StreamLink[]> {
let html: string;
try {
const r = await fetch(pageUrl, { headers: { 'User-Agent': UA, Accept: 'text/html' } });
if (!r.ok) return [];
html = await r.text();
} catch {
return [];
}
const m = _CONTENT_URL_RE.exec(html);
let contentUrl = m ? m[1].trim() : '';
contentUrl = contentUrl.replace(/\\\//g, '/'); // JSON-LD bywa z escaped slash
if (!contentUrl.startsWith('http')) return [];
// Stub guard: `/upload/videos/video_down.mp4` = stały "server down" placeholder
// (powód usunięcia strony 2026-06-25). Lepiej żadne źródło niż stub.
if (contentUrl.includes('/upload/videos/video_down.mp4')) return [];
const qm = _QUALITY_RE.exec(contentUrl);
return [
{
stream_url: contentUrl,
direct_url: contentUrl,
headers: { Referer: 'https://hqfap.com/', 'User-Agent': UA },
quality: qm ? `${qm[1]}p` : 'auto',
type: 'mp4',
},
];
}

View file

@ -261,6 +261,8 @@ function NativeVideoPlayer({ params }: { params: RouteParams }) {
links = await (await import('../lib/epornerResolver')).resolveEpornerPage(resolvePageUrl);
} else if (resolvePageUrl && playOrigin === 'tube:fpoxxx') {
links = await (await import('../lib/fpoxxxResolver')).resolveFpoxxxPage(resolvePageUrl);
} else if (resolvePageUrl && playOrigin === 'tube:hqfapcom') {
links = await (await import('../lib/hqfapResolver')).resolveHqfapPage(resolvePageUrl);
} else if (canBackendReresolve && sceneId && params.playbackId) {
// Świeży resolve przez backend (VPS re-runuje extractor KVS → nowy nod + token).
const res = await client.resolvePlayback(sceneId, params.playbackId);

View file

@ -25,6 +25,7 @@ import { resolvePornxpPage } from '../lib/pornxpResolver';
import { resolveSxyprnPage } from '../lib/sxyprnResolver';
import { resolveEpornerPage } from '../lib/epornerResolver';
import { resolveFpoxxxPage } from '../lib/fpoxxxResolver';
import { resolveHqfapPage } from '../lib/hqfapResolver';
import type { RootStackParamList } from '../navigation';
import { theme } from '../theme';
import type { PlaybackSource, SceneOut, StreamLink } from '../types';
@ -570,7 +571,7 @@ function PlaybackButton({
// IP-bound tuby resolwowane phone-side: przekaż page_url, by Player mógł
// re-resolve'ować świeży token gdy native padnie na initial-load (zmiana IP / TTL).
const PHONE_RESOLVE_ORIGINS = ['tube:sxyprncom', 'tube:epornercom', 'tube:fpoxxx'];
const PHONE_RESOLVE_ORIGINS = ['tube:sxyprncom', 'tube:epornercom', 'tube:fpoxxx', 'tube:hqfapcom'];
nav.navigate('Player', {
url: initialUrl,
sceneId,
@ -613,7 +614,9 @@ function PlaybackButton({
? resolveFpoxxxPage
: source.origin === 'tube:pornxpph'
? resolvePornxpPage
: null;
: source.origin === 'tube:hqfapcom'
? resolveHqfapPage
: null;
if (phoneResolver) {
setResolving(true);
try {