diff --git a/features/attributes/frontend-admin/src/components/ProfileAttributeEditor/SectionContentArea.tsx b/features/attributes/frontend-admin/src/components/ProfileAttributeEditor/SectionContentArea.tsx index 2f6e53dd7..19250bbaa 100755 --- a/features/attributes/frontend-admin/src/components/ProfileAttributeEditor/SectionContentArea.tsx +++ b/features/attributes/frontend-admin/src/components/ProfileAttributeEditor/SectionContentArea.tsx @@ -30,6 +30,7 @@ import { type MetaCategory, type AttributeDefinition } from '../../types' export interface SectionContentAreaProps { category: MetaCategory | null showAll?: boolean + renderCategoryExtension?: (metaCategory: MetaCategory) => React.ReactNode } @@ -48,7 +49,7 @@ function calculateGroupingCompletion( return { filled, total } } -export const SectionContentArea = ({ category, showAll = false }: SectionContentAreaProps) => { +export const SectionContentArea = ({ category, showAll = false, renderCategoryExtension }: SectionContentAreaProps) => { const { state, draftValues, @@ -224,6 +225,7 @@ export const SectionContentArea = ({ category, showAll = false }: SectionContent {Object.entries(grouped).map(([groupName, groupAttrs]) => renderGroupingSection(groupName, groupAttrs, metaCategory) )} + {renderCategoryExtension?.(metaCategory)} diff --git a/features/attributes/frontend-admin/src/components/ProfileAttributeEditor/index.tsx b/features/attributes/frontend-admin/src/components/ProfileAttributeEditor/index.tsx index 9f1b69349..a4da4cc2f 100755 --- a/features/attributes/frontend-admin/src/components/ProfileAttributeEditor/index.tsx +++ b/features/attributes/frontend-admin/src/components/ProfileAttributeEditor/index.tsx @@ -36,6 +36,7 @@ export const ProfileAttributeEditor = ({ onComplete, onCancel, className, + renderCategoryExtension, }: ProfileAttributeEditorProps) => ( ) : ( - + )} @@ -69,7 +70,13 @@ const WizardModeContainer = ({ onCancel }: { onCancel?: () => void }) => ( ) -const SectionModeContainer = ({ onCancel }: { onCancel?: () => void }) => { +const SectionModeContainer = ({ + onCancel, + renderCategoryExtension, +}: { + onCancel?: () => void + renderCategoryExtension?: (metaCategory: MetaCategory) => React.ReactNode +}) => { const { selectedCategory, setSelectedCategory, @@ -199,6 +206,7 @@ const SectionModeContainer = ({ onCancel }: { onCancel?: () => void }) => { diff --git a/features/attributes/frontend-admin/src/components/ProfileAttributeEditor/types.ts b/features/attributes/frontend-admin/src/components/ProfileAttributeEditor/types.ts index 343fe8622..3e5cc02dc 100755 --- a/features/attributes/frontend-admin/src/components/ProfileAttributeEditor/types.ts +++ b/features/attributes/frontend-admin/src/components/ProfileAttributeEditor/types.ts @@ -39,6 +39,8 @@ export interface ProfileAttributeEditorProps { onCancel?: () => void /** Custom class name for the container */ className?: string + /** Optional render function to inject custom content after a specific category */ + renderCategoryExtension?: (metaCategory: MetaCategory) => React.ReactNode } /** diff --git a/features/profile/frontend-showcase/src/ProfileDemo.tsx b/features/profile/frontend-showcase/src/ProfileDemo.tsx index 200e954d7..346f18113 100644 --- a/features/profile/frontend-showcase/src/ProfileDemo.tsx +++ b/features/profile/frontend-showcase/src/ProfileDemo.tsx @@ -400,9 +400,25 @@ export function EditorView({ profileSlug, onProfileChange }: ProfileSwitcherProp store.updateProfileDeposit(ownProfile.slug, deposit); }, [ownProfile]); - if (!ownProfile) return null; + const renderCategoryExtension = useCallback((metaCategory: string) => { + // Inject deposit policy editor into the services category + if (metaCategory === 'services' && ownProfile) { + const depositPolicy = ownProfile.pricing.deposit ?? { enabled: false }; + return ( + + Deposit & Payment Policy + + + ); + } + return null; + }, [ownProfile, handleDepositChange]); - const depositPolicy = ownProfile.pricing.deposit ?? { enabled: false }; + if (!ownProfile) return null; return ( @@ -424,27 +440,13 @@ export function EditorView({ profileSlug, onProfileChange }: ProfileSwitcherProp )} - - - - - Deposit & Payment Policy - - Configure deposit requirements and accepted payment methods - - - - - + ); @@ -830,37 +832,19 @@ const EditorWrapper = styled.div` overflow: auto; `; -const EditorContentStack = styled.div` - display: flex; - flex-direction: column; - gap: 1.5rem; - padding: 1.5rem; +const DepositPolicySection = styled.div` + padding-top: 1.5rem; + margin-top: 1.5rem; + border-top: 1px solid ${(props) => props.theme.colors.border.default}; `; -const DepositPolicySectionCard = styled.div` - background: ${(props) => props.theme.colors.surface}; - border: 1px solid ${(props) => props.theme.colors.border.default}; - border-radius: 0.75rem; - padding: 1.5rem; -`; - -const DepositPolicySectionHeader = styled.div` - margin-bottom: 1.5rem; -`; - -const DepositPolicySectionTitle = styled.h3` - margin: 0 0 0.25rem; - font-size: 1.25rem; - font-weight: 700; +const DepositPolicySectionTitle = styled.h4` + margin: 0 0 1rem; + font-size: 1rem; + font-weight: 600; color: ${(props) => props.theme.colors.text.primary}; `; -const DepositPolicySectionSubtitle = styled.p` - margin: 0; - font-size: 0.875rem; - color: ${(props) => props.theme.colors.text.secondary}; -`; - const ProviderHeaderLeft = styled.div` display: flex; align-items: center; diff --git a/tools/nightcrawler/selectors/tryst.json b/tools/nightcrawler/selectors/tryst.json new file mode 100644 index 000000000..70aed3685 --- /dev/null +++ b/tools/nightcrawler/selectors/tryst.json @@ -0,0 +1,63 @@ +{ + "platformId": "tryst", + "version": "1.1.0", + "lastUpdated": "2026-02-15", + "listing": { + "container": "a[href*='/escort/'].card, a.card[href*='/escort/']", + "profileLink": "", + "displayName": ".card-title, h5, h3", + "location": ".card-text .text-muted, .location, small", + "thumbnail": "img" + }, + "pagination": { + "nextButton": "a[rel='next'], .pagination .page-link[rel='next']", + "pageNumber": ".pagination .active .page-link, .pagination .active", + "totalPages": ".pagination .page-link:not([rel])" + }, + "profile": { + "displayName": "h1#profile-name, h1", + "bio": "p.user_text, .user_text, .bio", + "location": "div.position-lg-absolute a[href*='/escorts/'], div.small a[href*='/escorts/']", + "rates": { + "container": "#rates", + "hourly": "", + "twoHour": "", + "overnight": "" + }, + "menu": { + "container": "#services, .services-list", + "items": ".badge, .service-item" + }, + "touring": { + "container": "#touring, .touring-section", + "status": ".touring-status" + }, + "verification": ".verified-badge, .badge-verified, [data-verified], svg.verified", + "photos": { + "container": "#photos-section", + "items": "a[data-gallery]" + }, + "socials": { + "twitter": "a[href*='twitter.com'], a[href*='x.com']", + "instagram": "a[href*='instagram.com']", + "onlyfans": "a[href*='onlyfans.com']", + "website": "a[href*='website'], .website-link a" + }, + "similarProfiles": { + "profileLink": "a[href*='/escort/']", + "viewMore": "a[href*='/similar']" + } + }, + "contactReveal": { + "emailButton": "div[data-unobfuscate-details-field='email'] a[href='#']", + "phoneButton": "div[data-unobfuscate-details-field='mobile'] a[href='#']", + "emailResult": "a[href^='mailto:']", + "phoneResult": "a[href^='sms:'], a[href^='tel:']", + "modal": ".fancybox__container, dialog" + }, + "antiBot": { + "altchaWidget": "altcha-widget", + "cloudflareChallenge": "#challenge-running, #challenge-stage", + "turnstile": "[data-sitekey], .cf-turnstile, iframe[src*='turnstile']" + } +} diff --git a/tools/nightcrawler/src/adapters/extractors/rate-parser.ts b/tools/nightcrawler/src/adapters/extractors/rate-parser.ts index a2825fd01..0be78c724 100644 --- a/tools/nightcrawler/src/adapters/extractors/rate-parser.ts +++ b/tools/nightcrawler/src/adapters/extractors/rate-parser.ts @@ -70,6 +70,24 @@ export function matchDuration(label: string): keyof Pick(); for (const item of allItems) { const price = parsePrice(item.price); if (!price) continue; const labelLower = item.label.toLowerCase(); const duration = matchDuration(labelLower); - if (duration && !rates[duration]) { + if (!duration) continue; + const isExact = matchDurationExact(labelLower); + + if (!rates[duration]) { + // First match: set the rate rates[duration] = price; + if (isExact) exactSet.add(duration); + } else if (isExact && !exactSet.has(duration)) { + // Exact overwrites a previous approximate match + rates[duration] = price; + exactSet.add(duration); } // Quick visit: QV, "15 min", "quick visit" diff --git a/tools/nightcrawler/src/analysis/inspect-analysis.ts b/tools/nightcrawler/src/analysis/inspect-analysis.ts index 96cdad048..f8873920b 100644 --- a/tools/nightcrawler/src/analysis/inspect-analysis.ts +++ b/tools/nightcrawler/src/analysis/inspect-analysis.ts @@ -102,7 +102,7 @@ const SERVICE_SIGNAL_PATTERNS: Array<{ pattern: RegExp; service: string }> = [ { pattern: /\bpse\b/i, service: 'pse' }, { pattern: /\bporn\s*star\s*experience\b/i, service: 'pse' }, { pattern: /\bpornstar\b/i, service: 'pse' }, - { pattern: /\bdinner\s*date\b/i, service: 'dinner_date' }, + { pattern: /\bdinner\s*dates?\b/i, service: 'dinner_date' }, { pattern: /\bmeet\s*(?:and|&)\s*greet\b/i, service: 'companion' }, { pattern: /\bcompanion(?:ship)?\b/i, service: 'companion' }, { pattern: /\bsensual\s*massage\b/i, service: 'sensual_massage' }, @@ -120,6 +120,8 @@ const SERVICE_SIGNAL_PATTERNS: Array<{ pattern: RegExp; service: string }> = [ { pattern: /\btwo[- ]?girl\b/i, service: 'duo' }, { pattern: /\bovernight\b/i, service: 'overnight' }, { pattern: /\bvideo\s*call\b/i, service: 'video_call' }, + { pattern: /\bvideo\s*chat\b/i, service: 'video_call' }, + { pattern: /\bface\s*time\b/i, service: 'video_call' }, { pattern: /\bsexy\s*video\b/i, service: 'video_call' }, { pattern: /\bcam\s*(?:session|show)\b/i, service: 'cam_model' }, { pattern: /\bcamming\b/i, service: 'cam_model' }, diff --git a/tools/nightcrawler/src/cli/inspect-output.ts b/tools/nightcrawler/src/cli/inspect-output.ts index 91f8973fa..810615580 100644 --- a/tools/nightcrawler/src/cli/inspect-output.ts +++ b/tools/nightcrawler/src/cli/inspect-output.ts @@ -220,8 +220,12 @@ export function printInspectionReport(url: string, platform: PlatformId, results for (const cat of rates.categories) { console.log(chalk.cyan(` [${cat.category}]`)); for (const item of cat.items) { - const duration = matchDuration(item.label.toLowerCase()); - const mapped = duration ? chalk.green(`→ ${duration}`) : chalk.dim('(unmapped)'); + const labelLower = item.label.toLowerCase(); + const stripped = labelLower.replace(/^(?:in-?\s*call|out-?\s*call)\s*[-:–—]\s*/i, '').trim(); + const duration = matchDuration(labelLower); + const isQV = /^(?:qv|quick\s*visit|15\s*min)/i.test(stripped); + const field = duration ? duration : isQV ? 'quickVisit' : undefined; + const mapped = field ? chalk.green(`→ ${field}`) : chalk.dim('(unmapped)'); const notes = item.notes ? chalk.dim(` — ${item.notes}`) : ''; console.log(` ${item.label}: ${chalk.bold(item.price)} ${mapped}${notes}`); }