@striderlabs/mcp-thumbtack 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/browser.d.ts +12 -0
- package/dist/browser.js +88 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +30 -0
- package/dist/server.d.ts +3 -0
- package/dist/server.js +276 -0
- package/dist/session.d.ts +5 -0
- package/dist/session.js +99 -0
- package/dist/tools/get-projects.d.ts +27 -0
- package/dist/tools/get-projects.js +180 -0
- package/dist/tools/get-provider.d.ts +40 -0
- package/dist/tools/get-provider.js +186 -0
- package/dist/tools/hire-provider.d.ts +13 -0
- package/dist/tools/hire-provider.js +195 -0
- package/dist/tools/request-quote.d.ts +20 -0
- package/dist/tools/request-quote.js +234 -0
- package/dist/tools/search-services.d.ts +28 -0
- package/dist/tools/search-services.js +152 -0
- package/dist/tools/view-quotes.d.ts +25 -0
- package/dist/tools/view-quotes.js +163 -0
- package/package.json +30 -0
- package/server.json +14 -0
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import { getBrowserManager } from "../browser.js";
|
|
2
|
+
import { loadSession, saveSession } from "../session.js";
|
|
3
|
+
export async function getProjects(params) {
|
|
4
|
+
const manager = getBrowserManager();
|
|
5
|
+
await manager.init();
|
|
6
|
+
const page = await manager.getPage();
|
|
7
|
+
const context = manager.getContext();
|
|
8
|
+
const sessionLoaded = await loadSession(page, context);
|
|
9
|
+
// Navigate to projects page
|
|
10
|
+
await page.goto("https://www.thumbtack.com/projects", {
|
|
11
|
+
waitUntil: "domcontentloaded",
|
|
12
|
+
timeout: 30000,
|
|
13
|
+
});
|
|
14
|
+
await page.waitForTimeout(3000);
|
|
15
|
+
// Check if redirected to login
|
|
16
|
+
const currentUrl = page.url();
|
|
17
|
+
const requiresLogin = currentUrl.includes("/login") ||
|
|
18
|
+
currentUrl.includes("/signup") ||
|
|
19
|
+
!sessionLoaded;
|
|
20
|
+
if (requiresLogin) {
|
|
21
|
+
// Also check page content for auth wall
|
|
22
|
+
const hasAuthWall = await page.evaluate(() => {
|
|
23
|
+
return (!!document.querySelector('[class*="AuthWall"], [class*="LoginWall"]') ||
|
|
24
|
+
window.location.href.includes("/login"));
|
|
25
|
+
});
|
|
26
|
+
if (hasAuthWall) {
|
|
27
|
+
return {
|
|
28
|
+
projects: [],
|
|
29
|
+
totalCount: 0,
|
|
30
|
+
requiresLogin: true,
|
|
31
|
+
message: "Authentication required. Please log in to Thumbtack first. Visit https://www.thumbtack.com/login to sign in.",
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
// Apply status filter if provided
|
|
36
|
+
if (params.status && params.status !== "all") {
|
|
37
|
+
try {
|
|
38
|
+
const filterMap = {
|
|
39
|
+
active: ["Active", "In Progress", "active"],
|
|
40
|
+
completed: ["Completed", "Done", "completed"],
|
|
41
|
+
};
|
|
42
|
+
const filterTexts = filterMap[params.status] || [];
|
|
43
|
+
for (const filterText of filterTexts) {
|
|
44
|
+
try {
|
|
45
|
+
const btn = await page.$(`button:has-text("${filterText}"), [role="tab"]:has-text("${filterText}")`);
|
|
46
|
+
if (btn) {
|
|
47
|
+
await btn.click();
|
|
48
|
+
await page.waitForTimeout(1500);
|
|
49
|
+
break;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
// Filter may not be available
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
// Extract projects from the page
|
|
62
|
+
const projects = await page.evaluate(() => {
|
|
63
|
+
const results = [];
|
|
64
|
+
// Look for project cards
|
|
65
|
+
const cardSelectors = [
|
|
66
|
+
'[data-test="project-card"]',
|
|
67
|
+
'[class*="ProjectCard"]',
|
|
68
|
+
'[class*="ProjectItem"]',
|
|
69
|
+
'[class*="project-card"]',
|
|
70
|
+
"article[class*='Project']",
|
|
71
|
+
];
|
|
72
|
+
let cards = [];
|
|
73
|
+
for (const selector of cardSelectors) {
|
|
74
|
+
const found = document.querySelectorAll(selector);
|
|
75
|
+
if (found.length > 0) {
|
|
76
|
+
cards = found;
|
|
77
|
+
break;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
cards.forEach((card) => {
|
|
81
|
+
try {
|
|
82
|
+
// Extract project title
|
|
83
|
+
const titleEl = card.querySelector('[data-test="project-title"], [class*="ProjectTitle"], h3, h2');
|
|
84
|
+
const title = titleEl?.textContent?.trim() || "Untitled Project";
|
|
85
|
+
// Extract provider name
|
|
86
|
+
const providerNameEl = card.querySelector('[data-test="pro-name"], [class*="ProviderName"]');
|
|
87
|
+
const providerName = providerNameEl?.textContent?.trim() || "Unknown Provider";
|
|
88
|
+
// Extract provider URL
|
|
89
|
+
const linkEl = card.querySelector('a[href*="/ca/"], a[href*="/pro/"]');
|
|
90
|
+
const providerUrl = linkEl?.href || null;
|
|
91
|
+
// Extract project URL
|
|
92
|
+
const projectLinkEl = card.querySelector('a[href*="/projects/"]');
|
|
93
|
+
const projectUrl = projectLinkEl?.href || null;
|
|
94
|
+
// Extract project ID
|
|
95
|
+
const idFromData = card.getAttribute("data-project-id") || card.getAttribute("data-id");
|
|
96
|
+
const idFromUrl = projectUrl?.match(/\/projects\/(\w+)/)?.[1];
|
|
97
|
+
const id = idFromData || idFromUrl || Math.random().toString(36).substr(2, 9);
|
|
98
|
+
// Extract service type
|
|
99
|
+
const serviceTypeEl = card.querySelector('[data-test="service-type"], [class*="ServiceType"], [class*="Category"]');
|
|
100
|
+
const serviceType = serviceTypeEl?.textContent?.trim() || "Service";
|
|
101
|
+
// Extract status
|
|
102
|
+
const statusEl = card.querySelector('[data-test="status"], [class*="Status"], [class*="badge"]');
|
|
103
|
+
const status = statusEl?.textContent?.trim() || "active";
|
|
104
|
+
// Extract dates
|
|
105
|
+
const startDateEl = card.querySelector('[data-test="start-date"], [class*="StartDate"], time');
|
|
106
|
+
const startDate = startDateEl?.getAttribute("datetime") || startDateEl?.textContent?.trim() || null;
|
|
107
|
+
const completedDateEl = card.querySelector('[data-test="completed-date"], [class*="CompletedDate"]');
|
|
108
|
+
const completedDate = completedDateEl?.getAttribute("datetime") || completedDateEl?.textContent?.trim() || null;
|
|
109
|
+
// Extract price
|
|
110
|
+
const priceEl = card.querySelector('[data-test="price"], [class*="Price"], [class*="Amount"]');
|
|
111
|
+
const price = priceEl?.textContent?.trim() || null;
|
|
112
|
+
// Extract location
|
|
113
|
+
const locationEl = card.querySelector('[data-test="location"], [class*="Location"]');
|
|
114
|
+
const location = locationEl?.textContent?.trim() || null;
|
|
115
|
+
// Extract description
|
|
116
|
+
const descEl = card.querySelector('[data-test="description"], [class*="Description"], p');
|
|
117
|
+
const description = descEl?.textContent?.trim() || null;
|
|
118
|
+
// Check if has review
|
|
119
|
+
const reviewEl = card.querySelector('[data-test="review-badge"], [class*="ReviewBadge"], [class*="Reviewed"]');
|
|
120
|
+
const hasReview = !!reviewEl;
|
|
121
|
+
// Extract rating if reviewed
|
|
122
|
+
const ratingEl = card.querySelector('[aria-label*="star"], [class*="Rating"]');
|
|
123
|
+
const ratingText = ratingEl?.getAttribute("aria-label") || ratingEl?.textContent || "";
|
|
124
|
+
const ratingMatch = ratingText.match(/(\d+\.?\d*)/);
|
|
125
|
+
const rating = ratingMatch ? parseFloat(ratingMatch[1]) : null;
|
|
126
|
+
// Provider image
|
|
127
|
+
const imgEl = card.querySelector("img");
|
|
128
|
+
const providerImageUrl = imgEl?.src || null;
|
|
129
|
+
results.push({
|
|
130
|
+
id,
|
|
131
|
+
title,
|
|
132
|
+
providerName,
|
|
133
|
+
providerUrl,
|
|
134
|
+
providerImageUrl,
|
|
135
|
+
serviceType,
|
|
136
|
+
status,
|
|
137
|
+
startDate,
|
|
138
|
+
completedDate,
|
|
139
|
+
price,
|
|
140
|
+
location,
|
|
141
|
+
description,
|
|
142
|
+
hasReview,
|
|
143
|
+
rating,
|
|
144
|
+
projectUrl,
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
catch {
|
|
148
|
+
// Skip malformed cards
|
|
149
|
+
}
|
|
150
|
+
});
|
|
151
|
+
return results;
|
|
152
|
+
});
|
|
153
|
+
// Apply status filter in JS if server-side filter didn't work
|
|
154
|
+
let filteredProjects = projects;
|
|
155
|
+
if (params.status && params.status !== "all") {
|
|
156
|
+
filteredProjects = projects.filter((p) => {
|
|
157
|
+
const statusLower = p.status.toLowerCase();
|
|
158
|
+
if (params.status === "active") {
|
|
159
|
+
return (statusLower.includes("active") ||
|
|
160
|
+
statusLower.includes("progress") ||
|
|
161
|
+
statusLower.includes("open"));
|
|
162
|
+
}
|
|
163
|
+
else if (params.status === "completed") {
|
|
164
|
+
return (statusLower.includes("complete") ||
|
|
165
|
+
statusLower.includes("done") ||
|
|
166
|
+
statusLower.includes("finished"));
|
|
167
|
+
}
|
|
168
|
+
return true;
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
await saveSession(page, context).catch(() => { });
|
|
172
|
+
return {
|
|
173
|
+
projects: filteredProjects,
|
|
174
|
+
totalCount: filteredProjects.length,
|
|
175
|
+
requiresLogin: false,
|
|
176
|
+
message: filteredProjects.length > 0
|
|
177
|
+
? `Found ${filteredProjects.length} project(s).`
|
|
178
|
+
: "No projects found for the specified filter.",
|
|
179
|
+
};
|
|
180
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
export interface GetProviderParams {
|
|
2
|
+
provider_id?: string;
|
|
3
|
+
provider_url?: string;
|
|
4
|
+
}
|
|
5
|
+
export interface Review {
|
|
6
|
+
author: string;
|
|
7
|
+
rating: number;
|
|
8
|
+
date: string;
|
|
9
|
+
text: string;
|
|
10
|
+
serviceType: string | null;
|
|
11
|
+
}
|
|
12
|
+
export interface ProviderDetails {
|
|
13
|
+
id: string;
|
|
14
|
+
name: string;
|
|
15
|
+
url: string;
|
|
16
|
+
profileImageUrl: string | null;
|
|
17
|
+
coverImageUrl: string | null;
|
|
18
|
+
rating: number | null;
|
|
19
|
+
reviewCount: number | null;
|
|
20
|
+
description: string;
|
|
21
|
+
location: string | null;
|
|
22
|
+
yearsInBusiness: number | null;
|
|
23
|
+
employees: string | null;
|
|
24
|
+
verified: boolean;
|
|
25
|
+
backgroundChecked: boolean;
|
|
26
|
+
licenseInfo: string | null;
|
|
27
|
+
insuranceInfo: string | null;
|
|
28
|
+
services: string[];
|
|
29
|
+
specialties: string[];
|
|
30
|
+
hireCount: number | null;
|
|
31
|
+
responseTime: string | null;
|
|
32
|
+
priceRange: string | null;
|
|
33
|
+
reviews: Review[];
|
|
34
|
+
contact: {
|
|
35
|
+
phone: string | null;
|
|
36
|
+
website: string | null;
|
|
37
|
+
};
|
|
38
|
+
socialLinks: Record<string, string>;
|
|
39
|
+
}
|
|
40
|
+
export declare function getProvider(params: GetProviderParams): Promise<ProviderDetails>;
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import { getBrowserManager } from "../browser.js";
|
|
2
|
+
import { loadSession, saveSession } from "../session.js";
|
|
3
|
+
export async function getProvider(params) {
|
|
4
|
+
if (!params.provider_id && !params.provider_url) {
|
|
5
|
+
throw new Error("Either provider_id or provider_url must be provided");
|
|
6
|
+
}
|
|
7
|
+
const manager = getBrowserManager();
|
|
8
|
+
await manager.init();
|
|
9
|
+
const page = await manager.getPage();
|
|
10
|
+
const context = manager.getContext();
|
|
11
|
+
await loadSession(page, context);
|
|
12
|
+
// Build provider URL
|
|
13
|
+
let providerUrl = params.provider_url;
|
|
14
|
+
if (!providerUrl && params.provider_id) {
|
|
15
|
+
providerUrl = `https://www.thumbtack.com/pro/${params.provider_id}`;
|
|
16
|
+
}
|
|
17
|
+
await page.goto(providerUrl, {
|
|
18
|
+
waitUntil: "domcontentloaded",
|
|
19
|
+
timeout: 30000,
|
|
20
|
+
});
|
|
21
|
+
await page.waitForTimeout(3000);
|
|
22
|
+
// Extract provider details from the page
|
|
23
|
+
const details = await page.evaluate(() => {
|
|
24
|
+
// Helper to safely query text content
|
|
25
|
+
const getText = (selector) => {
|
|
26
|
+
const el = document.querySelector(selector);
|
|
27
|
+
return el?.textContent?.trim() || "";
|
|
28
|
+
};
|
|
29
|
+
const getAttr = (selector, attr) => {
|
|
30
|
+
const el = document.querySelector(selector);
|
|
31
|
+
return el?.getAttribute(attr) || null;
|
|
32
|
+
};
|
|
33
|
+
// Name
|
|
34
|
+
const name = getText("h1") ||
|
|
35
|
+
getText('[data-test="pro-name"]') ||
|
|
36
|
+
getText('[class*="BusinessName"]') ||
|
|
37
|
+
"";
|
|
38
|
+
// Profile image
|
|
39
|
+
const profileImageUrl = getAttr('img[data-test="profile-image"]', "src") ||
|
|
40
|
+
getAttr('[class*="ProfileImage"] img', "src") ||
|
|
41
|
+
getAttr(".profile-image img", "src") ||
|
|
42
|
+
null;
|
|
43
|
+
// Cover/header image
|
|
44
|
+
const coverImageUrl = getAttr('[class*="CoverImage"] img', "src") ||
|
|
45
|
+
getAttr('[class*="hero"] img', "src") ||
|
|
46
|
+
null;
|
|
47
|
+
// Rating
|
|
48
|
+
const ratingEl = document.querySelector('[aria-label*="star"], [data-test="rating-score"], [class*="RatingScore"]');
|
|
49
|
+
const ratingText = ratingEl?.getAttribute("aria-label") || ratingEl?.textContent || "";
|
|
50
|
+
const ratingMatch = ratingText.match(/(\d+\.?\d*)/);
|
|
51
|
+
const rating = ratingMatch ? parseFloat(ratingMatch[1]) : null;
|
|
52
|
+
// Review count
|
|
53
|
+
const reviewCountEl = document.querySelector('[data-test="review-count"], [class*="ReviewCount"]');
|
|
54
|
+
const reviewCountText = reviewCountEl?.textContent || "";
|
|
55
|
+
const reviewCountMatch = reviewCountText.match(/(\d+)/);
|
|
56
|
+
const reviewCount = reviewCountMatch ? parseInt(reviewCountMatch[1]) : null;
|
|
57
|
+
// Description/intro
|
|
58
|
+
const description = getText('[data-test="intro-text"]') ||
|
|
59
|
+
getText('[class*="Introduction"]') ||
|
|
60
|
+
getText('[class*="Bio"]') ||
|
|
61
|
+
getText(".introduction") ||
|
|
62
|
+
"";
|
|
63
|
+
// Location
|
|
64
|
+
const location = getText('[data-test="location"]') ||
|
|
65
|
+
getText('[class*="Location"]') ||
|
|
66
|
+
getText('[class*="ServiceArea"]') ||
|
|
67
|
+
null;
|
|
68
|
+
// Years in business
|
|
69
|
+
const yearsEl = document.querySelector('[data-test="years-in-business"], [class*="YearsInBusiness"]');
|
|
70
|
+
const yearsText = yearsEl?.textContent || "";
|
|
71
|
+
const yearsMatch = yearsText.match(/(\d+)/);
|
|
72
|
+
const yearsInBusiness = yearsMatch ? parseInt(yearsMatch[1]) : null;
|
|
73
|
+
// Employees
|
|
74
|
+
const employees = getText('[data-test="employees"]') ||
|
|
75
|
+
getText('[class*="Employees"]') ||
|
|
76
|
+
null;
|
|
77
|
+
// Verification badges
|
|
78
|
+
const verified = !!document.querySelector('[data-test="verified-badge"], [aria-label*="verified"], [class*="Verified"]');
|
|
79
|
+
const backgroundChecked = !!document.querySelector('[data-test="background-check"], [aria-label*="background"], [class*="BackgroundCheck"]');
|
|
80
|
+
// License info
|
|
81
|
+
const licenseInfo = getText('[data-test="license"]') ||
|
|
82
|
+
getText('[class*="License"]') ||
|
|
83
|
+
null;
|
|
84
|
+
// Insurance info
|
|
85
|
+
const insuranceInfo = getText('[data-test="insurance"]') ||
|
|
86
|
+
getText('[class*="Insurance"]') ||
|
|
87
|
+
null;
|
|
88
|
+
// Services offered
|
|
89
|
+
const serviceEls = document.querySelectorAll('[data-test="service-item"], [class*="ServiceItem"], [class*="service-tag"]');
|
|
90
|
+
const services = [];
|
|
91
|
+
serviceEls.forEach((el) => {
|
|
92
|
+
const text = el.textContent?.trim();
|
|
93
|
+
if (text)
|
|
94
|
+
services.push(text);
|
|
95
|
+
});
|
|
96
|
+
// Specialties
|
|
97
|
+
const specialtyEls = document.querySelectorAll('[data-test="specialty"], [class*="Specialty"]');
|
|
98
|
+
const specialties = [];
|
|
99
|
+
specialtyEls.forEach((el) => {
|
|
100
|
+
const text = el.textContent?.trim();
|
|
101
|
+
if (text)
|
|
102
|
+
specialties.push(text);
|
|
103
|
+
});
|
|
104
|
+
// Hire count
|
|
105
|
+
const hireEl = document.querySelector('[data-test="hire-count"], [class*="HireCount"]');
|
|
106
|
+
const hireText = hireEl?.textContent || "";
|
|
107
|
+
const hireMatch = hireText.match(/(\d+)/);
|
|
108
|
+
const hireCount = hireMatch ? parseInt(hireMatch[1]) : null;
|
|
109
|
+
// Response time
|
|
110
|
+
const responseTime = getText('[data-test="response-time"]') ||
|
|
111
|
+
getText('[class*="ResponseTime"]') ||
|
|
112
|
+
null;
|
|
113
|
+
// Price range
|
|
114
|
+
const priceRange = getText('[data-test="price-range"]') ||
|
|
115
|
+
getText('[class*="PriceRange"]') ||
|
|
116
|
+
null;
|
|
117
|
+
// Extract reviews
|
|
118
|
+
const reviews = [];
|
|
119
|
+
const reviewEls = document.querySelectorAll('[data-test="review"], [class*="ReviewItem"], [class*="review-card"]');
|
|
120
|
+
reviewEls.forEach((reviewEl) => {
|
|
121
|
+
const author = reviewEl.querySelector('[class*="author"], [data-test="reviewer-name"]')?.textContent?.trim() || "Anonymous";
|
|
122
|
+
const reviewRatingEl = reviewEl.querySelector('[aria-label*="star"], [class*="rating"]');
|
|
123
|
+
const reviewRatingText = reviewRatingEl?.getAttribute("aria-label") || reviewRatingEl?.textContent || "";
|
|
124
|
+
const reviewRatingMatch = reviewRatingText.match(/(\d+\.?\d*)/);
|
|
125
|
+
const reviewRating = reviewRatingMatch ? parseFloat(reviewRatingMatch[1]) : 5;
|
|
126
|
+
const date = reviewEl.querySelector('[class*="date"], time')?.textContent?.trim() || "";
|
|
127
|
+
const text = reviewEl.querySelector('[class*="body"], [data-test="review-text"], p')?.textContent?.trim() || "";
|
|
128
|
+
const serviceType = reviewEl.querySelector('[class*="service"], [data-test="service-type"]')?.textContent?.trim() || null;
|
|
129
|
+
if (author || text) {
|
|
130
|
+
reviews.push({ author, rating: reviewRating, date, text, serviceType });
|
|
131
|
+
}
|
|
132
|
+
});
|
|
133
|
+
// Contact info
|
|
134
|
+
const phoneEl = document.querySelector('a[href^="tel:"], [data-test="phone-number"]');
|
|
135
|
+
const phone = phoneEl?.href?.replace("tel:", "") || phoneEl?.textContent?.trim() || null;
|
|
136
|
+
const websiteEl = document.querySelector('[data-test="website-link"], a[data-test="external-link"]');
|
|
137
|
+
const website = websiteEl?.href || null;
|
|
138
|
+
// Social links
|
|
139
|
+
const socialLinks = {};
|
|
140
|
+
const socialSelectors = {
|
|
141
|
+
facebook: 'a[href*="facebook.com"]',
|
|
142
|
+
instagram: 'a[href*="instagram.com"]',
|
|
143
|
+
twitter: 'a[href*="twitter.com"]',
|
|
144
|
+
linkedin: 'a[href*="linkedin.com"]',
|
|
145
|
+
yelp: 'a[href*="yelp.com"]',
|
|
146
|
+
};
|
|
147
|
+
for (const [platform, selector] of Object.entries(socialSelectors)) {
|
|
148
|
+
const el = document.querySelector(selector);
|
|
149
|
+
if (el?.href) {
|
|
150
|
+
socialLinks[platform] = el.href;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
return {
|
|
154
|
+
name,
|
|
155
|
+
profileImageUrl,
|
|
156
|
+
coverImageUrl,
|
|
157
|
+
rating,
|
|
158
|
+
reviewCount,
|
|
159
|
+
description,
|
|
160
|
+
location: location || null,
|
|
161
|
+
yearsInBusiness,
|
|
162
|
+
employees: employees || null,
|
|
163
|
+
verified,
|
|
164
|
+
backgroundChecked,
|
|
165
|
+
licenseInfo: licenseInfo || null,
|
|
166
|
+
insuranceInfo: insuranceInfo || null,
|
|
167
|
+
services,
|
|
168
|
+
specialties,
|
|
169
|
+
hireCount,
|
|
170
|
+
responseTime: responseTime || null,
|
|
171
|
+
priceRange: priceRange || null,
|
|
172
|
+
reviews: reviews.slice(0, 10),
|
|
173
|
+
contact: { phone, website },
|
|
174
|
+
socialLinks,
|
|
175
|
+
};
|
|
176
|
+
});
|
|
177
|
+
// Extract ID from URL
|
|
178
|
+
const urlMatch = page.url().match(/\/(\d+)\/?(?:\?.*)?$/);
|
|
179
|
+
const id = urlMatch ? urlMatch[1] : params.provider_id || "";
|
|
180
|
+
await saveSession(page, context).catch(() => { });
|
|
181
|
+
return {
|
|
182
|
+
id,
|
|
183
|
+
url: page.url(),
|
|
184
|
+
...details,
|
|
185
|
+
};
|
|
186
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export interface HireProviderParams {
|
|
2
|
+
quote_id: string;
|
|
3
|
+
provider_id: string;
|
|
4
|
+
}
|
|
5
|
+
export interface HireProviderResult {
|
|
6
|
+
success: boolean;
|
|
7
|
+
message: string;
|
|
8
|
+
projectId: string | null;
|
|
9
|
+
providerName: string | null;
|
|
10
|
+
requiresLogin: boolean;
|
|
11
|
+
nextSteps: string[];
|
|
12
|
+
}
|
|
13
|
+
export declare function hireProvider(params: HireProviderParams): Promise<HireProviderResult>;
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import { getBrowserManager } from "../browser.js";
|
|
2
|
+
import { loadSession, saveSession } from "../session.js";
|
|
3
|
+
export async function hireProvider(params) {
|
|
4
|
+
const manager = getBrowserManager();
|
|
5
|
+
await manager.init();
|
|
6
|
+
const page = await manager.getPage();
|
|
7
|
+
const context = manager.getContext();
|
|
8
|
+
const sessionLoaded = await loadSession(page, context);
|
|
9
|
+
if (!sessionLoaded) {
|
|
10
|
+
return {
|
|
11
|
+
success: false,
|
|
12
|
+
message: "Authentication required. Please log in to Thumbtack first.",
|
|
13
|
+
projectId: null,
|
|
14
|
+
providerName: null,
|
|
15
|
+
requiresLogin: true,
|
|
16
|
+
nextSteps: [
|
|
17
|
+
"Visit https://www.thumbtack.com/login to sign in",
|
|
18
|
+
"Once logged in, navigate to your inbox to find the quote",
|
|
19
|
+
"Click 'Hire' on the provider's quote",
|
|
20
|
+
],
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
// Navigate to the inbox/quote thread
|
|
24
|
+
await page.goto("https://www.thumbtack.com/inbox", {
|
|
25
|
+
waitUntil: "domcontentloaded",
|
|
26
|
+
timeout: 30000,
|
|
27
|
+
});
|
|
28
|
+
await page.waitForTimeout(2000);
|
|
29
|
+
// Check if redirected to login
|
|
30
|
+
const currentUrl = page.url();
|
|
31
|
+
if (currentUrl.includes("/login") || currentUrl.includes("/signup")) {
|
|
32
|
+
return {
|
|
33
|
+
success: false,
|
|
34
|
+
message: "Authentication required. Your session may have expired.",
|
|
35
|
+
projectId: null,
|
|
36
|
+
providerName: null,
|
|
37
|
+
requiresLogin: true,
|
|
38
|
+
nextSteps: [
|
|
39
|
+
"Visit https://www.thumbtack.com/login to sign in again",
|
|
40
|
+
"Then retry hiring the provider",
|
|
41
|
+
],
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
// Try to find and click on the specific quote/conversation
|
|
45
|
+
let providerName = null;
|
|
46
|
+
let foundQuote = false;
|
|
47
|
+
// Look for the quote with matching ID
|
|
48
|
+
try {
|
|
49
|
+
const quoteSelectors = [
|
|
50
|
+
`[data-quote-id="${params.quote_id}"]`,
|
|
51
|
+
`[data-id="${params.quote_id}"]`,
|
|
52
|
+
`a[href*="${params.quote_id}"]`,
|
|
53
|
+
`a[href*="${params.provider_id}"]`,
|
|
54
|
+
];
|
|
55
|
+
for (const selector of quoteSelectors) {
|
|
56
|
+
try {
|
|
57
|
+
const el = await page.$(selector);
|
|
58
|
+
if (el) {
|
|
59
|
+
await el.click();
|
|
60
|
+
foundQuote = true;
|
|
61
|
+
await page.waitForTimeout(2000);
|
|
62
|
+
break;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
if (!foundQuote) {
|
|
70
|
+
// Try navigating directly to the conversation
|
|
71
|
+
await page.goto(`https://www.thumbtack.com/inbox/${params.quote_id}`, { waitUntil: "domcontentloaded", timeout: 15000 });
|
|
72
|
+
await page.waitForTimeout(2000);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
// Navigation may have worked partially
|
|
77
|
+
}
|
|
78
|
+
// Get provider name from current page context
|
|
79
|
+
providerName = await page.evaluate(() => {
|
|
80
|
+
return (document.querySelector('[data-test="pro-name"], h1, h2')?.textContent?.trim() || null);
|
|
81
|
+
});
|
|
82
|
+
// Find and click the "Hire" button
|
|
83
|
+
try {
|
|
84
|
+
const hireButtonSelectors = [
|
|
85
|
+
'button[data-test="hire-button"]',
|
|
86
|
+
'button:has-text("Hire")',
|
|
87
|
+
'button:has-text("Hire this pro")',
|
|
88
|
+
'button:has-text("Mark as hired")',
|
|
89
|
+
'[class*="HireButton"]',
|
|
90
|
+
'a[data-test="hire-cta"]',
|
|
91
|
+
];
|
|
92
|
+
let hireClicked = false;
|
|
93
|
+
for (const selector of hireButtonSelectors) {
|
|
94
|
+
try {
|
|
95
|
+
const btn = await page.$(selector);
|
|
96
|
+
if (btn) {
|
|
97
|
+
await btn.click();
|
|
98
|
+
hireClicked = true;
|
|
99
|
+
await page.waitForTimeout(2000);
|
|
100
|
+
break;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
catch {
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
if (!hireClicked) {
|
|
108
|
+
return {
|
|
109
|
+
success: false,
|
|
110
|
+
message: "Could not find the 'Hire' button. The quote may have already been acted upon or the page structure has changed.",
|
|
111
|
+
projectId: null,
|
|
112
|
+
providerName,
|
|
113
|
+
requiresLogin: false,
|
|
114
|
+
nextSteps: [
|
|
115
|
+
"Visit https://www.thumbtack.com/inbox to find your quotes manually",
|
|
116
|
+
"Look for the provider's quote and click 'Hire' directly",
|
|
117
|
+
],
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
// Handle confirmation dialog if it appears
|
|
121
|
+
await page.waitForTimeout(1000);
|
|
122
|
+
try {
|
|
123
|
+
const confirmBtn = await page.$('button[data-test="confirm-hire"], button:has-text("Confirm"), button:has-text("Yes, hire")');
|
|
124
|
+
if (confirmBtn) {
|
|
125
|
+
await confirmBtn.click();
|
|
126
|
+
await page.waitForTimeout(2000);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
catch {
|
|
130
|
+
// No confirmation dialog
|
|
131
|
+
}
|
|
132
|
+
// Check for success state
|
|
133
|
+
const result = await page.evaluate(() => {
|
|
134
|
+
const successEl = document.querySelector('[data-test="hire-success"], [class*="HireSuccess"], [class*="HiredBadge"]');
|
|
135
|
+
const bodyText = document.body.textContent || "";
|
|
136
|
+
const isSuccess = !!successEl ||
|
|
137
|
+
bodyText.toLowerCase().includes("you hired") ||
|
|
138
|
+
bodyText.toLowerCase().includes("hired!") ||
|
|
139
|
+
bodyText.toLowerCase().includes("project created");
|
|
140
|
+
const projectIdEl = document.querySelector("[data-project-id]");
|
|
141
|
+
const projectId = projectIdEl?.getAttribute("data-project-id") || null;
|
|
142
|
+
// Extract next steps / success message
|
|
143
|
+
const nextStepsEls = document.querySelectorAll('[class*="NextStep"], [data-test="next-step"]');
|
|
144
|
+
const nextSteps = [];
|
|
145
|
+
nextStepsEls.forEach((el) => {
|
|
146
|
+
const text = el.textContent?.trim();
|
|
147
|
+
if (text)
|
|
148
|
+
nextSteps.push(text);
|
|
149
|
+
});
|
|
150
|
+
return { isSuccess, projectId, nextSteps };
|
|
151
|
+
});
|
|
152
|
+
await saveSession(page, context).catch(() => { });
|
|
153
|
+
if (result.isSuccess) {
|
|
154
|
+
return {
|
|
155
|
+
success: true,
|
|
156
|
+
message: `Successfully hired ${providerName || "the provider"}! A project has been created.`,
|
|
157
|
+
projectId: result.projectId,
|
|
158
|
+
providerName,
|
|
159
|
+
requiresLogin: false,
|
|
160
|
+
nextSteps: result.nextSteps.length > 0
|
|
161
|
+
? result.nextSteps
|
|
162
|
+
: [
|
|
163
|
+
"Communicate with your provider through the Thumbtack inbox",
|
|
164
|
+
"Agree on project details, timeline, and payment",
|
|
165
|
+
"Leave a review after the project is complete",
|
|
166
|
+
],
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
else {
|
|
170
|
+
return {
|
|
171
|
+
success: false,
|
|
172
|
+
message: "Hire action was attempted but confirmation could not be verified. Please check your Thumbtack account.",
|
|
173
|
+
projectId: null,
|
|
174
|
+
providerName,
|
|
175
|
+
requiresLogin: false,
|
|
176
|
+
nextSteps: [
|
|
177
|
+
"Visit https://www.thumbtack.com/projects to check if the project was created",
|
|
178
|
+
],
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
catch (error) {
|
|
183
|
+
const err = error;
|
|
184
|
+
return {
|
|
185
|
+
success: false,
|
|
186
|
+
message: `Error during hire process: ${err.message}`,
|
|
187
|
+
projectId: null,
|
|
188
|
+
providerName,
|
|
189
|
+
requiresLogin: false,
|
|
190
|
+
nextSteps: [
|
|
191
|
+
"Try again or visit https://www.thumbtack.com/inbox to hire manually",
|
|
192
|
+
],
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
export interface RequestQuoteParams {
|
|
2
|
+
provider_id: string;
|
|
3
|
+
service_type: string;
|
|
4
|
+
description: string;
|
|
5
|
+
location: string;
|
|
6
|
+
contact_info: {
|
|
7
|
+
name?: string;
|
|
8
|
+
email?: string;
|
|
9
|
+
phone?: string;
|
|
10
|
+
};
|
|
11
|
+
}
|
|
12
|
+
export interface QuoteRequestResult {
|
|
13
|
+
success: boolean;
|
|
14
|
+
message: string;
|
|
15
|
+
quoteId: string | null;
|
|
16
|
+
providerName: string | null;
|
|
17
|
+
estimatedResponseTime: string | null;
|
|
18
|
+
requiresLogin: boolean;
|
|
19
|
+
}
|
|
20
|
+
export declare function requestQuote(params: RequestQuoteParams): Promise<QuoteRequestResult>;
|