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}`);
}