@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,234 @@
|
|
|
1
|
+
import { getBrowserManager } from "../browser.js";
|
|
2
|
+
import { loadSession, saveSession } from "../session.js";
|
|
3
|
+
export async function requestQuote(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 provider profile
|
|
10
|
+
const providerUrl = `https://www.thumbtack.com/pro/${params.provider_id}`;
|
|
11
|
+
await page.goto(providerUrl, {
|
|
12
|
+
waitUntil: "domcontentloaded",
|
|
13
|
+
timeout: 30000,
|
|
14
|
+
});
|
|
15
|
+
await page.waitForTimeout(2000);
|
|
16
|
+
// Check for auth wall
|
|
17
|
+
const requiresLogin = await page.evaluate(() => {
|
|
18
|
+
const loginModal = document.querySelector('[data-test="login-modal"], [class*="AuthModal"], [class*="LoginWall"]');
|
|
19
|
+
const loginPage = window.location.href.includes("/login") ||
|
|
20
|
+
window.location.href.includes("/signup");
|
|
21
|
+
return !!loginModal || loginPage;
|
|
22
|
+
});
|
|
23
|
+
if (requiresLogin && !sessionLoaded) {
|
|
24
|
+
return {
|
|
25
|
+
success: false,
|
|
26
|
+
message: "Authentication required. Please log in to Thumbtack first. Visit https://www.thumbtack.com/login to sign in, then try again.",
|
|
27
|
+
quoteId: null,
|
|
28
|
+
providerName: null,
|
|
29
|
+
estimatedResponseTime: null,
|
|
30
|
+
requiresLogin: true,
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
// Get provider name for confirmation
|
|
34
|
+
const providerName = await page.evaluate(() => {
|
|
35
|
+
return document.querySelector("h1")?.textContent?.trim() || null;
|
|
36
|
+
});
|
|
37
|
+
// Find and click the "Get a quote" or "Contact" button
|
|
38
|
+
try {
|
|
39
|
+
const quoteButtonSelectors = [
|
|
40
|
+
'button[data-test="contact-button"]',
|
|
41
|
+
'button[data-test="get-quote-button"]',
|
|
42
|
+
'a[data-test="contact-cta"]',
|
|
43
|
+
'button:has-text("Get a quote")',
|
|
44
|
+
'button:has-text("Contact")',
|
|
45
|
+
'button:has-text("Request quote")',
|
|
46
|
+
'[class*="ContactButton"]',
|
|
47
|
+
'[class*="QuoteButton"]',
|
|
48
|
+
];
|
|
49
|
+
let clicked = false;
|
|
50
|
+
for (const selector of quoteButtonSelectors) {
|
|
51
|
+
try {
|
|
52
|
+
const btn = await page.$(selector);
|
|
53
|
+
if (btn) {
|
|
54
|
+
await btn.click();
|
|
55
|
+
clicked = true;
|
|
56
|
+
break;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
if (!clicked) {
|
|
64
|
+
// Try text-based approach
|
|
65
|
+
await page.locator('button', { hasText: /get a quote|contact|request/i }).first().click();
|
|
66
|
+
}
|
|
67
|
+
await page.waitForTimeout(2000);
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
return {
|
|
71
|
+
success: false,
|
|
72
|
+
message: "Could not find the quote request button on this provider's page. The page structure may have changed.",
|
|
73
|
+
quoteId: null,
|
|
74
|
+
providerName,
|
|
75
|
+
estimatedResponseTime: null,
|
|
76
|
+
requiresLogin: false,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
// Fill out the quote request form
|
|
80
|
+
try {
|
|
81
|
+
// Wait for form to appear
|
|
82
|
+
await page.waitForSelector('textarea, input[type="text"], [class*="QuoteForm"]', { timeout: 5000 });
|
|
83
|
+
// Fill description/project details
|
|
84
|
+
const descriptionSelectors = [
|
|
85
|
+
'textarea[data-test="description"]',
|
|
86
|
+
'textarea[name="description"]',
|
|
87
|
+
'textarea[placeholder*="describe"]',
|
|
88
|
+
'textarea[placeholder*="project"]',
|
|
89
|
+
"textarea",
|
|
90
|
+
];
|
|
91
|
+
for (const selector of descriptionSelectors) {
|
|
92
|
+
try {
|
|
93
|
+
const textarea = await page.$(selector);
|
|
94
|
+
if (textarea) {
|
|
95
|
+
await textarea.click();
|
|
96
|
+
await textarea.fill(params.description);
|
|
97
|
+
break;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
catch {
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
// Fill location if there's a location field
|
|
105
|
+
const locationSelectors = [
|
|
106
|
+
'input[placeholder*="ZIP"]',
|
|
107
|
+
'input[placeholder*="zip"]',
|
|
108
|
+
'input[name="zipCode"]',
|
|
109
|
+
'input[data-test="location"]',
|
|
110
|
+
];
|
|
111
|
+
for (const selector of locationSelectors) {
|
|
112
|
+
try {
|
|
113
|
+
const input = await page.$(selector);
|
|
114
|
+
if (input) {
|
|
115
|
+
await input.click({ clickCount: 3 });
|
|
116
|
+
await input.fill(params.location);
|
|
117
|
+
break;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
catch {
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
// Fill contact info if fields are available
|
|
125
|
+
if (params.contact_info.name) {
|
|
126
|
+
try {
|
|
127
|
+
const nameInput = await page.$('input[name="name"], input[placeholder*="name"]');
|
|
128
|
+
if (nameInput) {
|
|
129
|
+
await nameInput.fill(params.contact_info.name);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
catch { }
|
|
133
|
+
}
|
|
134
|
+
if (params.contact_info.email) {
|
|
135
|
+
try {
|
|
136
|
+
const emailInput = await page.$('input[type="email"], input[name="email"]');
|
|
137
|
+
if (emailInput) {
|
|
138
|
+
await emailInput.fill(params.contact_info.email);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
catch { }
|
|
142
|
+
}
|
|
143
|
+
if (params.contact_info.phone) {
|
|
144
|
+
try {
|
|
145
|
+
const phoneInput = await page.$('input[type="tel"], input[name="phone"]');
|
|
146
|
+
if (phoneInput) {
|
|
147
|
+
await phoneInput.fill(params.contact_info.phone);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
catch { }
|
|
151
|
+
}
|
|
152
|
+
// Look for submit button
|
|
153
|
+
const submitSelectors = [
|
|
154
|
+
'button[type="submit"]',
|
|
155
|
+
'button[data-test="submit-quote"]',
|
|
156
|
+
'button:has-text("Send request")',
|
|
157
|
+
'button:has-text("Submit")',
|
|
158
|
+
'button:has-text("Send")',
|
|
159
|
+
];
|
|
160
|
+
let submitted = false;
|
|
161
|
+
for (const selector of submitSelectors) {
|
|
162
|
+
try {
|
|
163
|
+
const btn = await page.$(selector);
|
|
164
|
+
if (btn) {
|
|
165
|
+
await btn.click();
|
|
166
|
+
submitted = true;
|
|
167
|
+
break;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
catch {
|
|
171
|
+
continue;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
if (!submitted) {
|
|
175
|
+
return {
|
|
176
|
+
success: false,
|
|
177
|
+
message: "Form was filled but could not find submit button. The form may require additional steps.",
|
|
178
|
+
quoteId: null,
|
|
179
|
+
providerName,
|
|
180
|
+
estimatedResponseTime: null,
|
|
181
|
+
requiresLogin: false,
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
// Wait for confirmation
|
|
185
|
+
await page.waitForTimeout(3000);
|
|
186
|
+
// Check for success confirmation
|
|
187
|
+
const confirmationResult = await page.evaluate(() => {
|
|
188
|
+
const successEl = document.querySelector('[data-test="quote-sent"], [class*="SuccessMessage"], [class*="Confirmation"]');
|
|
189
|
+
const successText = document.body.textContent || "";
|
|
190
|
+
const isSuccess = !!successEl ||
|
|
191
|
+
successText.includes("request sent") ||
|
|
192
|
+
successText.includes("quote sent") ||
|
|
193
|
+
successText.includes("message sent") ||
|
|
194
|
+
successText.toLowerCase().includes("we'll notify");
|
|
195
|
+
const quoteIdEl = document.querySelector("[data-quote-id]");
|
|
196
|
+
const quoteId = quoteIdEl?.getAttribute("data-quote-id") || null;
|
|
197
|
+
const responseTimeEl = document.querySelector('[class*="ResponseTime"], [data-test="response-time"]');
|
|
198
|
+
const responseTime = responseTimeEl?.textContent?.trim() || null;
|
|
199
|
+
return { isSuccess, quoteId, responseTime };
|
|
200
|
+
});
|
|
201
|
+
await saveSession(page, context).catch(() => { });
|
|
202
|
+
if (confirmationResult.isSuccess) {
|
|
203
|
+
return {
|
|
204
|
+
success: true,
|
|
205
|
+
message: `Quote request successfully sent to ${providerName || "provider"}. They will be notified and should respond soon.`,
|
|
206
|
+
quoteId: confirmationResult.quoteId,
|
|
207
|
+
providerName,
|
|
208
|
+
estimatedResponseTime: confirmationResult.responseTime,
|
|
209
|
+
requiresLogin: false,
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
else {
|
|
213
|
+
return {
|
|
214
|
+
success: false,
|
|
215
|
+
message: "Quote request may have been submitted but confirmation could not be verified. Check your Thumbtack inbox.",
|
|
216
|
+
quoteId: null,
|
|
217
|
+
providerName,
|
|
218
|
+
estimatedResponseTime: null,
|
|
219
|
+
requiresLogin: false,
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
catch (error) {
|
|
224
|
+
const err = error;
|
|
225
|
+
return {
|
|
226
|
+
success: false,
|
|
227
|
+
message: `Error during quote request process: ${err.message}`,
|
|
228
|
+
quoteId: null,
|
|
229
|
+
providerName,
|
|
230
|
+
estimatedResponseTime: null,
|
|
231
|
+
requiresLogin: false,
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
export interface SearchServicesParams {
|
|
2
|
+
service_type: string;
|
|
3
|
+
location?: string;
|
|
4
|
+
zip_code?: string;
|
|
5
|
+
date?: string;
|
|
6
|
+
num_people?: number;
|
|
7
|
+
}
|
|
8
|
+
export interface ServiceProvider {
|
|
9
|
+
id: string;
|
|
10
|
+
name: string;
|
|
11
|
+
url: string;
|
|
12
|
+
rating: number | null;
|
|
13
|
+
reviewCount: number | null;
|
|
14
|
+
description: string;
|
|
15
|
+
priceRange: string | null;
|
|
16
|
+
verified: boolean;
|
|
17
|
+
responseTime: string | null;
|
|
18
|
+
hireCount: number | null;
|
|
19
|
+
location: string | null;
|
|
20
|
+
profileImageUrl: string | null;
|
|
21
|
+
}
|
|
22
|
+
export interface SearchResult {
|
|
23
|
+
query: string;
|
|
24
|
+
location: string;
|
|
25
|
+
providers: ServiceProvider[];
|
|
26
|
+
totalFound: number;
|
|
27
|
+
}
|
|
28
|
+
export declare function searchServices(params: SearchServicesParams): Promise<SearchResult>;
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import { getBrowserManager } from "../browser.js";
|
|
2
|
+
import { loadSession, saveSession } from "../session.js";
|
|
3
|
+
export async function searchServices(params) {
|
|
4
|
+
const manager = getBrowserManager();
|
|
5
|
+
await manager.init();
|
|
6
|
+
const page = await manager.getPage();
|
|
7
|
+
const context = manager.getContext();
|
|
8
|
+
// Try to load existing session
|
|
9
|
+
const sessionLoaded = await loadSession(page, context);
|
|
10
|
+
// Build search URL
|
|
11
|
+
const locationQuery = params.zip_code || params.location || "";
|
|
12
|
+
const encodedService = encodeURIComponent(params.service_type);
|
|
13
|
+
const encodedLocation = encodeURIComponent(locationQuery);
|
|
14
|
+
// Navigate to Thumbtack search
|
|
15
|
+
const searchUrl = `https://www.thumbtack.com/search/${encodedService}/cost`;
|
|
16
|
+
await page.goto(searchUrl, { waitUntil: "domcontentloaded", timeout: 30000 });
|
|
17
|
+
// Wait for page to load and check if we need to fill in location
|
|
18
|
+
await page.waitForTimeout(2000);
|
|
19
|
+
// Handle location input if present
|
|
20
|
+
try {
|
|
21
|
+
const locationInput = await page.$('input[placeholder*="ZIP"], input[placeholder*="zip"], input[name="zipCode"], input[data-test="location-input"]');
|
|
22
|
+
if (locationInput && locationQuery) {
|
|
23
|
+
await locationInput.click({ clickCount: 3 });
|
|
24
|
+
await locationInput.fill(locationQuery);
|
|
25
|
+
await page.keyboard.press("Enter");
|
|
26
|
+
await page.waitForTimeout(2000);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
// Location input may not be present on all search pages
|
|
31
|
+
}
|
|
32
|
+
// Also try the main search form approach
|
|
33
|
+
try {
|
|
34
|
+
const serviceInput = await page.$('input[placeholder*="service"], input[name="service"], input[data-test="service-input"]');
|
|
35
|
+
if (serviceInput) {
|
|
36
|
+
await serviceInput.click({ clickCount: 3 });
|
|
37
|
+
await serviceInput.fill(params.service_type);
|
|
38
|
+
}
|
|
39
|
+
const zipInput = await page.$('input[placeholder*="ZIP"], input[placeholder*="zip code"], input[name="zipCode"]');
|
|
40
|
+
if (zipInput && locationQuery) {
|
|
41
|
+
await zipInput.click({ clickCount: 3 });
|
|
42
|
+
await zipInput.fill(locationQuery);
|
|
43
|
+
}
|
|
44
|
+
const searchBtn = await page.$('button[type="submit"], button[data-test="search-button"]');
|
|
45
|
+
if (searchBtn && serviceInput) {
|
|
46
|
+
await searchBtn.click();
|
|
47
|
+
await page.waitForTimeout(3000);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
// Search form approach failed, continue with what we have
|
|
52
|
+
}
|
|
53
|
+
// Wait for results to load
|
|
54
|
+
await page.waitForTimeout(3000);
|
|
55
|
+
// Extract provider data from the page
|
|
56
|
+
const providers = await page.evaluate(() => {
|
|
57
|
+
const results = [];
|
|
58
|
+
// Look for provider cards - Thumbtack uses various selectors
|
|
59
|
+
const cardSelectors = [
|
|
60
|
+
'[data-test="provider-card"]',
|
|
61
|
+
'[class*="ProviderCard"]',
|
|
62
|
+
'[class*="provider-card"]',
|
|
63
|
+
"article[class*='Card']",
|
|
64
|
+
'[data-testid="pro-card"]',
|
|
65
|
+
".Grid__Item",
|
|
66
|
+
];
|
|
67
|
+
let cards = [];
|
|
68
|
+
for (const selector of cardSelectors) {
|
|
69
|
+
const found = document.querySelectorAll(selector);
|
|
70
|
+
if (found.length > 0) {
|
|
71
|
+
cards = found;
|
|
72
|
+
break;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
cards.forEach((card) => {
|
|
76
|
+
try {
|
|
77
|
+
// Extract name
|
|
78
|
+
const nameEl = card.querySelector('h3, h2, [data-test="pro-name"], [class*="name"], a[href*="/ca/"]');
|
|
79
|
+
const name = nameEl?.textContent?.trim() || "";
|
|
80
|
+
// Extract URL
|
|
81
|
+
const linkEl = card.querySelector('a[href*="/ca/"], a[href*="/pro/"]');
|
|
82
|
+
const url = linkEl?.href || "";
|
|
83
|
+
// Extract ID from URL
|
|
84
|
+
const idMatch = url.match(/\/(\d+)\/?$/);
|
|
85
|
+
const id = idMatch ? idMatch[1] : Math.random().toString(36).substr(2, 9);
|
|
86
|
+
// Extract rating
|
|
87
|
+
const ratingEl = card.querySelector('[class*="rating"], [aria-label*="star"], [data-test="rating"]');
|
|
88
|
+
const ratingText = ratingEl?.getAttribute("aria-label") || ratingEl?.textContent || "";
|
|
89
|
+
const ratingMatch = ratingText.match(/(\d+\.?\d*)/);
|
|
90
|
+
const rating = ratingMatch ? parseFloat(ratingMatch[1]) : null;
|
|
91
|
+
// Extract review count
|
|
92
|
+
const reviewEl = card.querySelector('[class*="review"], [data-test="review-count"]');
|
|
93
|
+
const reviewText = reviewEl?.textContent || "";
|
|
94
|
+
const reviewMatch = reviewText.match(/(\d+)/);
|
|
95
|
+
const reviewCount = reviewMatch ? parseInt(reviewMatch[1]) : null;
|
|
96
|
+
// Extract description/summary
|
|
97
|
+
const descEl = card.querySelector('[class*="description"], [class*="summary"], p');
|
|
98
|
+
const description = descEl?.textContent?.trim() || "";
|
|
99
|
+
// Extract price range
|
|
100
|
+
const priceEl = card.querySelector('[class*="price"], [data-test="price-range"]');
|
|
101
|
+
const priceRange = priceEl?.textContent?.trim() || null;
|
|
102
|
+
// Check verified badge
|
|
103
|
+
const verifiedEl = card.querySelector('[class*="verified"], [data-test="verified-badge"], [aria-label*="verified"]');
|
|
104
|
+
const verified = !!verifiedEl;
|
|
105
|
+
// Extract response time
|
|
106
|
+
const responseEl = card.querySelector('[class*="response"], [data-test="response-time"]');
|
|
107
|
+
const responseTime = responseEl?.textContent?.trim() || null;
|
|
108
|
+
// Extract hire count
|
|
109
|
+
const hireEl = card.querySelector('[class*="hire"], [data-test="hire-count"]');
|
|
110
|
+
const hireText = hireEl?.textContent || "";
|
|
111
|
+
const hireMatch = hireText.match(/(\d+)/);
|
|
112
|
+
const hireCount = hireMatch ? parseInt(hireMatch[1]) : null;
|
|
113
|
+
// Extract location
|
|
114
|
+
const locationEl = card.querySelector('[class*="location"], [data-test="location"]');
|
|
115
|
+
const location = locationEl?.textContent?.trim() || null;
|
|
116
|
+
// Extract profile image
|
|
117
|
+
const imgEl = card.querySelector("img");
|
|
118
|
+
const profileImageUrl = imgEl?.src || null;
|
|
119
|
+
if (name || url) {
|
|
120
|
+
results.push({
|
|
121
|
+
id,
|
|
122
|
+
name,
|
|
123
|
+
url,
|
|
124
|
+
rating,
|
|
125
|
+
reviewCount,
|
|
126
|
+
description,
|
|
127
|
+
priceRange,
|
|
128
|
+
verified,
|
|
129
|
+
responseTime,
|
|
130
|
+
hireCount,
|
|
131
|
+
location,
|
|
132
|
+
profileImageUrl,
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
catch {
|
|
137
|
+
// Skip malformed cards
|
|
138
|
+
}
|
|
139
|
+
});
|
|
140
|
+
return results;
|
|
141
|
+
});
|
|
142
|
+
// Save session after successful navigation
|
|
143
|
+
if (sessionLoaded || providers.length > 0) {
|
|
144
|
+
await saveSession(page, context).catch(() => { });
|
|
145
|
+
}
|
|
146
|
+
return {
|
|
147
|
+
query: params.service_type,
|
|
148
|
+
location: locationQuery,
|
|
149
|
+
providers: providers.slice(0, 20), // Limit to top 20 results
|
|
150
|
+
totalFound: providers.length,
|
|
151
|
+
};
|
|
152
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
export interface ViewQuotesParams {
|
|
2
|
+
status?: "pending" | "active" | "completed" | "archived" | "all";
|
|
3
|
+
}
|
|
4
|
+
export interface Quote {
|
|
5
|
+
id: string;
|
|
6
|
+
providerName: string;
|
|
7
|
+
providerUrl: string | null;
|
|
8
|
+
serviceType: string;
|
|
9
|
+
status: string;
|
|
10
|
+
price: string | null;
|
|
11
|
+
message: string | null;
|
|
12
|
+
date: string | null;
|
|
13
|
+
expiresAt: string | null;
|
|
14
|
+
providerRating: number | null;
|
|
15
|
+
providerReviewCount: number | null;
|
|
16
|
+
providerImageUrl: string | null;
|
|
17
|
+
unread: boolean;
|
|
18
|
+
}
|
|
19
|
+
export interface ViewQuotesResult {
|
|
20
|
+
quotes: Quote[];
|
|
21
|
+
totalCount: number;
|
|
22
|
+
requiresLogin: boolean;
|
|
23
|
+
message: string;
|
|
24
|
+
}
|
|
25
|
+
export declare function viewQuotes(params: ViewQuotesParams): Promise<ViewQuotesResult>;
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import { getBrowserManager } from "../browser.js";
|
|
2
|
+
import { loadSession, saveSession } from "../session.js";
|
|
3
|
+
export async function viewQuotes(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 inbox/quotes section
|
|
10
|
+
await page.goto("https://www.thumbtack.com/inbox", {
|
|
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 && currentUrl.includes("thumbtack.com/login"));
|
|
20
|
+
if (requiresLogin) {
|
|
21
|
+
return {
|
|
22
|
+
quotes: [],
|
|
23
|
+
totalCount: 0,
|
|
24
|
+
requiresLogin: true,
|
|
25
|
+
message: "Authentication required. Please log in to Thumbtack first. Visit https://www.thumbtack.com/login to sign in.",
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
// Apply status filter if provided
|
|
29
|
+
if (params.status && params.status !== "all") {
|
|
30
|
+
const filterMap = {
|
|
31
|
+
pending: "pending",
|
|
32
|
+
active: "active",
|
|
33
|
+
completed: "completed",
|
|
34
|
+
archived: "archived",
|
|
35
|
+
};
|
|
36
|
+
const filterValue = filterMap[params.status];
|
|
37
|
+
if (filterValue) {
|
|
38
|
+
try {
|
|
39
|
+
// Look for filter tabs/buttons
|
|
40
|
+
const filterSelectors = [
|
|
41
|
+
`[data-test="filter-${filterValue}"]`,
|
|
42
|
+
`button:has-text("${filterValue}")`,
|
|
43
|
+
`a:has-text("${filterValue}")`,
|
|
44
|
+
`[role="tab"]:has-text("${filterValue}")`,
|
|
45
|
+
];
|
|
46
|
+
for (const selector of filterSelectors) {
|
|
47
|
+
try {
|
|
48
|
+
const btn = await page.$(selector);
|
|
49
|
+
if (btn) {
|
|
50
|
+
await btn.click();
|
|
51
|
+
await page.waitForTimeout(1500);
|
|
52
|
+
break;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
// Filter may not be available
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
// Extract quotes from the page
|
|
66
|
+
const quotes = await page.evaluate(() => {
|
|
67
|
+
const results = [];
|
|
68
|
+
// Look for quote/message cards in the inbox
|
|
69
|
+
const cardSelectors = [
|
|
70
|
+
'[data-test="inbox-item"]',
|
|
71
|
+
'[data-test="quote-card"]',
|
|
72
|
+
'[class*="InboxItem"]',
|
|
73
|
+
'[class*="QuoteCard"]',
|
|
74
|
+
'[class*="MessageThread"]',
|
|
75
|
+
'[class*="ConversationItem"]',
|
|
76
|
+
];
|
|
77
|
+
let cards = [];
|
|
78
|
+
for (const selector of cardSelectors) {
|
|
79
|
+
const found = document.querySelectorAll(selector);
|
|
80
|
+
if (found.length > 0) {
|
|
81
|
+
cards = found;
|
|
82
|
+
break;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
cards.forEach((card) => {
|
|
86
|
+
try {
|
|
87
|
+
// Extract provider name
|
|
88
|
+
const providerNameEl = card.querySelector('[class*="ProviderName"], [data-test="pro-name"], h3, h2');
|
|
89
|
+
const providerName = providerNameEl?.textContent?.trim() || "Unknown Provider";
|
|
90
|
+
// Extract provider URL
|
|
91
|
+
const linkEl = card.querySelector('a[href*="/ca/"], a[href*="/pro/"]');
|
|
92
|
+
const providerUrl = linkEl?.href || null;
|
|
93
|
+
// Extract quote ID from URL or data attribute
|
|
94
|
+
const idEl = card.querySelector("[data-quote-id], [data-id]");
|
|
95
|
+
const idFromData = idEl?.getAttribute("data-quote-id") || idEl?.getAttribute("data-id");
|
|
96
|
+
const idFromUrl = linkEl?.href?.match(/\/(\d+)\/?/)?.[1];
|
|
97
|
+
const id = idFromData || idFromUrl || Math.random().toString(36).substr(2, 9);
|
|
98
|
+
// Extract service type
|
|
99
|
+
const serviceTypeEl = card.querySelector('[class*="ServiceType"], [data-test="service-type"], [class*="Category"]');
|
|
100
|
+
const serviceType = serviceTypeEl?.textContent?.trim() || "Service";
|
|
101
|
+
// Extract status
|
|
102
|
+
const statusEl = card.querySelector('[class*="Status"], [data-test="status"], [class*="badge"]');
|
|
103
|
+
const status = statusEl?.textContent?.trim() || "unknown";
|
|
104
|
+
// Extract price
|
|
105
|
+
const priceEl = card.querySelector('[class*="Price"], [data-test="price"], [class*="amount"]');
|
|
106
|
+
const price = priceEl?.textContent?.trim() || null;
|
|
107
|
+
// Extract message preview
|
|
108
|
+
const messageEl = card.querySelector('[class*="Preview"], [class*="Snippet"], [data-test="message-preview"], p');
|
|
109
|
+
const message = messageEl?.textContent?.trim() || null;
|
|
110
|
+
// Extract date
|
|
111
|
+
const dateEl = card.querySelector('time, [class*="Date"], [data-test="date"]');
|
|
112
|
+
const date = dateEl?.getAttribute("datetime") || dateEl?.textContent?.trim() || null;
|
|
113
|
+
// Extract expiry
|
|
114
|
+
const expiresEl = card.querySelector('[class*="Expires"], [data-test="expires"]');
|
|
115
|
+
const expiresAt = expiresEl?.textContent?.trim() || null;
|
|
116
|
+
// Provider rating
|
|
117
|
+
const ratingEl = card.querySelector('[aria-label*="star"], [class*="Rating"]');
|
|
118
|
+
const ratingText = ratingEl?.getAttribute("aria-label") || ratingEl?.textContent || "";
|
|
119
|
+
const ratingMatch = ratingText.match(/(\d+\.?\d*)/);
|
|
120
|
+
const providerRating = ratingMatch ? parseFloat(ratingMatch[1]) : null;
|
|
121
|
+
// Provider review count
|
|
122
|
+
const reviewEl = card.querySelector('[class*="ReviewCount"]');
|
|
123
|
+
const reviewText = reviewEl?.textContent || "";
|
|
124
|
+
const reviewMatch = reviewText.match(/(\d+)/);
|
|
125
|
+
const providerReviewCount = reviewMatch ? parseInt(reviewMatch[1]) : null;
|
|
126
|
+
// Provider image
|
|
127
|
+
const imgEl = card.querySelector("img");
|
|
128
|
+
const providerImageUrl = imgEl?.src || null;
|
|
129
|
+
// Check if unread
|
|
130
|
+
const unread = card.classList.contains("unread") ||
|
|
131
|
+
!!card.querySelector('[class*="unread"], [class*="Unread"]');
|
|
132
|
+
results.push({
|
|
133
|
+
id,
|
|
134
|
+
providerName,
|
|
135
|
+
providerUrl,
|
|
136
|
+
serviceType,
|
|
137
|
+
status,
|
|
138
|
+
price,
|
|
139
|
+
message,
|
|
140
|
+
date,
|
|
141
|
+
expiresAt,
|
|
142
|
+
providerRating,
|
|
143
|
+
providerReviewCount,
|
|
144
|
+
providerImageUrl,
|
|
145
|
+
unread,
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
catch {
|
|
149
|
+
// Skip malformed cards
|
|
150
|
+
}
|
|
151
|
+
});
|
|
152
|
+
return results;
|
|
153
|
+
});
|
|
154
|
+
await saveSession(page, context).catch(() => { });
|
|
155
|
+
return {
|
|
156
|
+
quotes,
|
|
157
|
+
totalCount: quotes.length,
|
|
158
|
+
requiresLogin: false,
|
|
159
|
+
message: quotes.length > 0
|
|
160
|
+
? `Found ${quotes.length} quote(s) in your inbox.`
|
|
161
|
+
: "No quotes found in your inbox for the specified filter.",
|
|
162
|
+
};
|
|
163
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@striderlabs/mcp-thumbtack",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "MCP connector for Thumbtack home services marketplace",
|
|
5
|
+
"author": "Strider Labs <hello@striderlabs.ai>",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"type": "module",
|
|
8
|
+
"main": "dist/index.js",
|
|
9
|
+
"bin": {
|
|
10
|
+
"mcp-thumbtack": "dist/index.js"
|
|
11
|
+
},
|
|
12
|
+
"scripts": {
|
|
13
|
+
"build": "tsc",
|
|
14
|
+
"start": "node dist/index.js",
|
|
15
|
+
"dev": "ts-node src/index.ts"
|
|
16
|
+
},
|
|
17
|
+
"dependencies": {
|
|
18
|
+
"@modelcontextprotocol/sdk": "^1.0.0",
|
|
19
|
+
"playwright": "^1.40.0"
|
|
20
|
+
},
|
|
21
|
+
"devDependencies": {
|
|
22
|
+
"@types/node": "^20.0.0",
|
|
23
|
+
"typescript": "^5.0.0"
|
|
24
|
+
},
|
|
25
|
+
"files": [
|
|
26
|
+
"dist/",
|
|
27
|
+
"server.json",
|
|
28
|
+
"README.md"
|
|
29
|
+
]
|
|
30
|
+
}
|
package/server.json
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
{
|
|
2
|
+
"mcpName": "io.github.markswendsen-code/mcp-thumbtack",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "MCP connector for Thumbtack home services marketplace",
|
|
5
|
+
"author": "Strider Labs <hello@striderlabs.ai>",
|
|
6
|
+
"tools": [
|
|
7
|
+
"thumbtack_search_services",
|
|
8
|
+
"thumbtack_get_provider",
|
|
9
|
+
"thumbtack_request_quote",
|
|
10
|
+
"thumbtack_view_quotes",
|
|
11
|
+
"thumbtack_hire_provider",
|
|
12
|
+
"thumbtack_get_projects"
|
|
13
|
+
]
|
|
14
|
+
}
|