@coldiq/mcp 0.2.7 → 0.3.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/index.js +4 -0
- package/dist/index.js.map +1 -1
- package/dist/registry.d.ts +1 -1
- package/dist/registry.d.ts.map +1 -1
- package/dist/registry.js +100 -13
- package/dist/registry.js.map +1 -1
- package/dist/tools/extract-post-engagement.d.ts +21 -0
- package/dist/tools/extract-post-engagement.d.ts.map +1 -0
- package/dist/tools/extract-post-engagement.js +117 -0
- package/dist/tools/extract-post-engagement.js.map +1 -0
- package/dist/tools/find-influencers.d.ts +1 -1
- package/dist/tools/find-influencers.d.ts.map +1 -1
- package/dist/tools/find-influencers.js +2 -1
- package/dist/tools/find-influencers.js.map +1 -1
- package/dist/tools/find-signals.d.ts.map +1 -1
- package/dist/tools/find-signals.js +27 -10
- package/dist/tools/find-signals.js.map +1 -1
- package/dist/tools/get-place-reviews.d.ts +24 -0
- package/dist/tools/get-place-reviews.d.ts.map +1 -0
- package/dist/tools/get-place-reviews.js +46 -0
- package/dist/tools/get-place-reviews.js.map +1 -0
- package/dist/tools/search-ads.d.ts +1 -1
- package/dist/tools/search-ads.d.ts.map +1 -1
- package/dist/tools/search-ads.js +1 -1
- package/dist/tools/search-ads.js.map +1 -1
- package/dist/tools/search-places.d.ts +1 -1
- package/dist/tools/search-places.d.ts.map +1 -1
- package/dist/tools/search-places.js +23 -3
- package/dist/tools/search-places.js.map +1 -1
- package/dist/tools/search-reddit.d.ts +8 -5
- package/dist/tools/search-reddit.d.ts.map +1 -1
- package/dist/tools/search-reddit.js +15 -9
- package/dist/tools/search-reddit.js.map +1 -1
- package/package.json +1 -1
- package/src/index.ts +16 -0
- package/src/registry.ts +96 -7
- package/src/tools/extract-post-engagement.ts +135 -0
- package/src/tools/find-influencers.ts +2 -1
- package/src/tools/find-signals.ts +28 -11
- package/src/tools/get-place-reviews.ts +50 -0
- package/src/tools/search-ads.ts +1 -1
- package/src/tools/search-places.ts +22 -3
- package/src/tools/search-reddit.ts +15 -9
- package/tests/registry-find-signals.test.ts +66 -0
- package/tests/registry.test.ts +18 -4
- package/tests/tools/extract-post-engagement.test.ts +76 -0
- package/tests/tools/find-signals.test.ts +5 -2
- package/tests/tools/get-place-reviews.test.ts +73 -0
- package/tests/tools/search-reddit.test.ts +69 -0
|
@@ -5,25 +5,31 @@ import { resolvePreferredProviders, getProvidersForCapability } from '../utils/p
|
|
|
5
5
|
export const searchRedditName = 'search_reddit'
|
|
6
6
|
|
|
7
7
|
export const searchRedditDescription =
|
|
8
|
-
'Scrape Reddit posts
|
|
8
|
+
'Scrape Reddit posts, comments, communities, or users via 1 provider (Reddit Scraper). Provide subreddit/post/user URLs and/or a keyword query. Optionally scope a query to one community, sort, filter by time, include comments, and limit by date. Async (~30–120s). Cost: 1 credit per item returned.'
|
|
9
9
|
|
|
10
10
|
export const searchRedditSchema = {
|
|
11
|
-
start_urls: z.array(z.string().url()).
|
|
12
|
-
.describe('Reddit URLs to scrape (subreddit, post, or search URL).
|
|
11
|
+
start_urls: z.array(z.string().url()).max(25).optional()
|
|
12
|
+
.describe('Reddit URLs to scrape (subreddit, post, user, or search URL). Up to 25. Provide this and/or query. Example: ["https://www.reddit.com/r/sales/"]'),
|
|
13
13
|
query: z.string().optional()
|
|
14
|
-
.describe('
|
|
15
|
-
|
|
16
|
-
.describe('
|
|
17
|
-
|
|
14
|
+
.describe('Keyword search query e.g. "best CRM for startups". Provide this and/or start_urls. When combined with a bare subreddit start_url (e.g. ".../r/sales/"), the query is applied as an in-subreddit search so only matching posts are returned (a bare subreddit URL alone would otherwise return its whole feed, ignoring the keyword).'),
|
|
15
|
+
search_type: z.enum(['posts', 'comments', 'communities', 'users']).default('posts')
|
|
16
|
+
.describe('What the search query returns: posts, comments, communities, or users.'),
|
|
17
|
+
search_community_name: z.string().optional()
|
|
18
|
+
.describe('Restrict the search query to a single community e.g. "sales".'),
|
|
19
|
+
sort: z.enum(['relevance', 'hot', 'top', 'new', 'rising', 'comments']).optional()
|
|
18
20
|
.describe('Sort order for results.'),
|
|
19
21
|
time: z.enum(['hour', 'day', 'week', 'month', 'year', 'all']).optional()
|
|
20
22
|
.describe('Time filter.'),
|
|
21
23
|
limit: z.number().int().min(1).max(200).default(10)
|
|
22
24
|
.describe('Max items to return (1–200). 1 credit per item.'),
|
|
23
|
-
|
|
24
|
-
.describe('Max comments per post
|
|
25
|
+
max_comments: z.number().int().min(0).max(1000).optional()
|
|
26
|
+
.describe('Max comments to fetch per post. Set 0 to skip comments.'),
|
|
25
27
|
include_comments: z.boolean().optional()
|
|
26
28
|
.describe('Include comments alongside posts.'),
|
|
29
|
+
post_date_limit: z.string().optional()
|
|
30
|
+
.describe('Only return posts published on or after this date (ISO 8601, e.g. "2025-01-01").'),
|
|
31
|
+
comment_date_limit: z.string().optional()
|
|
32
|
+
.describe('Only return comments published on or after this date (ISO 8601, e.g. "2025-01-01").'),
|
|
27
33
|
use_providers: z.array(z.string()).optional().describe(`Optional ordered list of providers to use. Leave empty to let ColdIQ automatically pick the best tool for your inputs — recommended for most use cases. Available providers: ${getProvidersForCapability('search_reddit').join(', ')}. Provider names are matched fuzzily, so minor typos are tolerated.`),
|
|
28
34
|
}
|
|
29
35
|
|
|
@@ -309,6 +309,72 @@ describe('signalbase-job-change', () => {
|
|
|
309
309
|
})
|
|
310
310
|
})
|
|
311
311
|
|
|
312
|
+
// ---------------------------------------------------------------------------
|
|
313
|
+
// theirstack-intent-discovery
|
|
314
|
+
// ---------------------------------------------------------------------------
|
|
315
|
+
|
|
316
|
+
describe('theirstack-intent-discovery', () => {
|
|
317
|
+
const p = () => get('theirstack-intent-discovery')
|
|
318
|
+
|
|
319
|
+
it('routes to the company search endpoint', () => {
|
|
320
|
+
expect(p().endpoint).toBe('/theirstack/companies/search')
|
|
321
|
+
expect(p().method).toBe('POST')
|
|
322
|
+
})
|
|
323
|
+
|
|
324
|
+
it('isApplicable: true for intent with topics and no companies/domains', () => {
|
|
325
|
+
expect(p().isApplicable!({ signal_type: 'intent', topics: ['sales-automation'] })).toBe(true)
|
|
326
|
+
})
|
|
327
|
+
|
|
328
|
+
it('isApplicable: false when companies present (verify mode handles that)', () => {
|
|
329
|
+
expect(p().isApplicable!({ signal_type: 'intent', topics: ['sales-automation'], companies: ['ColdIQ'] })).toBe(false)
|
|
330
|
+
})
|
|
331
|
+
|
|
332
|
+
it('isApplicable: false when domains present', () => {
|
|
333
|
+
expect(p().isApplicable!({ signal_type: 'intent', topics: ['sales-automation'], domains: ['coldiq.com'] })).toBe(false)
|
|
334
|
+
})
|
|
335
|
+
|
|
336
|
+
it('isApplicable: false when no topics', () => {
|
|
337
|
+
expect(p().isApplicable!({ signal_type: 'intent' })).toBe(false)
|
|
338
|
+
})
|
|
339
|
+
|
|
340
|
+
it('isApplicable: false for other signal types', () => {
|
|
341
|
+
expect(p().isApplicable!({ signal_type: 'funding', topics: ['x'] })).toBe(false)
|
|
342
|
+
})
|
|
343
|
+
|
|
344
|
+
it('mapParams forwards topics as company_keyword_slug_or', () => {
|
|
345
|
+
const result = p().mapParams({ signal_type: 'intent', topics: ['sales-automation', 'lead-generation'], limit: 20 })
|
|
346
|
+
const body = result.body as Record<string, unknown>
|
|
347
|
+
expect(body.company_keyword_slug_or).toEqual(['sales-automation', 'lead-generation'])
|
|
348
|
+
expect(body.limit).toBe(20)
|
|
349
|
+
expect(body.include_total_results).toBe(true)
|
|
350
|
+
})
|
|
351
|
+
|
|
352
|
+
it('mapParams forwards industries and countries when present', () => {
|
|
353
|
+
const result = p().mapParams({
|
|
354
|
+
signal_type: 'intent',
|
|
355
|
+
topics: ['sales-automation'],
|
|
356
|
+
industries: ['Software'],
|
|
357
|
+
countries: ['US', 'GB'],
|
|
358
|
+
})
|
|
359
|
+
const body = result.body as Record<string, unknown>
|
|
360
|
+
expect(body.industry_or).toEqual(['Software'])
|
|
361
|
+
expect(body.company_country_code_or).toEqual(['US', 'GB'])
|
|
362
|
+
})
|
|
363
|
+
|
|
364
|
+
it('mapParams caps limit at 100', () => {
|
|
365
|
+
const result = p().mapParams({ signal_type: 'intent', topics: ['x'], limit: 999 })
|
|
366
|
+
expect((result.body as Record<string, unknown>).limit).toBe(100)
|
|
367
|
+
})
|
|
368
|
+
|
|
369
|
+
it('hasResult: true when data non-empty', () => {
|
|
370
|
+
expect(p().hasResult({ data: [{ name: 'ColdIQ' }] })).toBe(true)
|
|
371
|
+
})
|
|
372
|
+
|
|
373
|
+
it('hasResult: false on empty data', () => {
|
|
374
|
+
expect(p().hasResult({ data: [] })).toBe(false)
|
|
375
|
+
})
|
|
376
|
+
})
|
|
377
|
+
|
|
312
378
|
// ---------------------------------------------------------------------------
|
|
313
379
|
// theirstack-buying-intents
|
|
314
380
|
// ---------------------------------------------------------------------------
|
package/tests/registry.test.ts
CHANGED
|
@@ -1936,11 +1936,11 @@ describe('registry', () => {
|
|
|
1936
1936
|
expect(() => r.async!.extractId({})).toThrow('no jobId')
|
|
1937
1937
|
})
|
|
1938
1938
|
|
|
1939
|
-
it('reddit.isApplicable: true
|
|
1939
|
+
it('reddit.isApplicable: true when start_urls is non-empty or a query string is provided', () => {
|
|
1940
1940
|
const r = getProviders('search_reddit').find((p) => p.id === 'reddit')!
|
|
1941
1941
|
expect(r.isApplicable!({ start_urls: ['https://reddit.com/r/sales/'] })).toBe(true)
|
|
1942
1942
|
expect(r.isApplicable!({ query: 'x', start_urls: ['https://reddit.com/r/sales/'] })).toBe(true)
|
|
1943
|
-
expect(r.isApplicable!({ query: 'B2B sales' })).toBe(
|
|
1943
|
+
expect(r.isApplicable!({ query: 'B2B sales' })).toBe(true)
|
|
1944
1944
|
expect(r.isApplicable!({})).toBe(false)
|
|
1945
1945
|
expect(r.isApplicable!({ start_urls: [] })).toBe(false)
|
|
1946
1946
|
})
|
|
@@ -1949,18 +1949,32 @@ describe('registry', () => {
|
|
|
1949
1949
|
const r = getProviders('search_reddit').find((p) => p.id === 'reddit')!
|
|
1950
1950
|
const out = r.mapParams({
|
|
1951
1951
|
query: 'best CRM',
|
|
1952
|
-
|
|
1952
|
+
search_type: 'communities',
|
|
1953
|
+
search_community_name: 'sales',
|
|
1953
1954
|
sort: 'top',
|
|
1954
1955
|
time: 'month',
|
|
1955
1956
|
limit: 10,
|
|
1956
1957
|
include_comments: true,
|
|
1958
|
+
max_comments: 5,
|
|
1959
|
+
post_date_limit: '2025-01-01',
|
|
1960
|
+
comment_date_limit: '2025-02-01',
|
|
1957
1961
|
}).body as Record<string, unknown>
|
|
1958
1962
|
expect(out.searchQueries).toEqual(['best CRM'])
|
|
1959
|
-
expect(out.
|
|
1963
|
+
expect(out.searchType).toBe('communities')
|
|
1964
|
+
expect(out.searchCommunityName).toBe('sales')
|
|
1960
1965
|
expect(out.sort).toBe('top')
|
|
1961
1966
|
expect(out.time).toBe('month')
|
|
1962
1967
|
expect(out.maxItems).toBe(10)
|
|
1963
1968
|
expect(out.includeComments).toBe(true)
|
|
1969
|
+
expect(out.maxComments).toBe(5)
|
|
1970
|
+
expect(out.postDateLimit).toBe('2025-01-01')
|
|
1971
|
+
expect(out.commentDateLimit).toBe('2025-02-01')
|
|
1972
|
+
})
|
|
1973
|
+
|
|
1974
|
+
it('reddit.mapParams: defaults searchType to posts', () => {
|
|
1975
|
+
const r = getProviders('search_reddit').find((p) => p.id === 'reddit')!
|
|
1976
|
+
const out = r.mapParams({ query: 'x' }).body as Record<string, unknown>
|
|
1977
|
+
expect(out.searchType).toBe('posts')
|
|
1964
1978
|
})
|
|
1965
1979
|
|
|
1966
1980
|
it('reddit.mapParams: clamps maxItems to [1,200]', () => {
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
|
2
|
+
import { initClient } from '../../src/client.js'
|
|
3
|
+
import { extractPostEngagementHandler } from '../../src/tools/extract-post-engagement.js'
|
|
4
|
+
|
|
5
|
+
const POST_URL = 'https://www.linkedin.com/feed/update/urn:li:activity:7234567890123456789'
|
|
6
|
+
|
|
7
|
+
function res(body: unknown, status = 200, headers?: Record<string, string>) {
|
|
8
|
+
return new Response(JSON.stringify(body), { status, headers })
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
describe('extract_post_engagement handler', () => {
|
|
12
|
+
const originalFetch = globalThis.fetch
|
|
13
|
+
|
|
14
|
+
beforeEach(() => {
|
|
15
|
+
initClient('http://test-api.local', 'test-key')
|
|
16
|
+
vi.stubEnv('COLDIQ_ENGAGEMENT_POLL_MS', '1')
|
|
17
|
+
vi.stubEnv('COLDIQ_ENGAGEMENT_TIMEOUT_MS', '5000')
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
afterEach(() => {
|
|
21
|
+
globalThis.fetch = originalFetch
|
|
22
|
+
vi.unstubAllEnvs()
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
it('runs create → poll → fetch contacts and returns the people', async () => {
|
|
26
|
+
const people = [{ name: 'Alex Vacca', profile_url: 'https://www.linkedin.com/in/alexvacca' }]
|
|
27
|
+
globalThis.fetch = vi.fn(async (url: string | URL) => {
|
|
28
|
+
const u = url.toString()
|
|
29
|
+
if (u.includes('/jungler/workbooks/wb_1/contacts')) return res(people)
|
|
30
|
+
if (u.includes('/jungler/tasks/task_1/status')) return res({ task_id: 'task_1', status: 'success', workbook_id: 'wb_1' })
|
|
31
|
+
if (u.includes('/jungler/workbooks')) return res({ task_id: 'task_1', status: 'PENDING' }, 200, { 'x-coldiq-credits-charged': '10', 'x-coldiq-credits-remaining': '90' })
|
|
32
|
+
throw new Error(`unexpected url ${u}`)
|
|
33
|
+
}) as typeof fetch
|
|
34
|
+
|
|
35
|
+
const result = await extractPostEngagementHandler({ post_url: POST_URL, type: 'both' })
|
|
36
|
+
const parsed = JSON.parse(result.content[0].text)
|
|
37
|
+
expect(result.isError).toBeUndefined()
|
|
38
|
+
expect(parsed.data.people).toEqual(people)
|
|
39
|
+
expect(parsed.data.type).toBe('both')
|
|
40
|
+
expect(parsed._meta.credits_charged).toBe(10)
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
it('passes activity_filter=commenters when type=comments', async () => {
|
|
44
|
+
let contactsUrl = ''
|
|
45
|
+
globalThis.fetch = vi.fn(async (url: string | URL) => {
|
|
46
|
+
const u = url.toString()
|
|
47
|
+
if (u.includes('/jungler/workbooks/wb_1/contacts')) { contactsUrl = u; return res([]) }
|
|
48
|
+
if (u.includes('/jungler/tasks/task_1/status')) return res({ task_id: 'task_1', status: 'success', workbook_id: 'wb_1' })
|
|
49
|
+
if (u.includes('/jungler/workbooks')) return res({ task_id: 'task_1', status: 'PENDING' })
|
|
50
|
+
throw new Error(`unexpected url ${u}`)
|
|
51
|
+
}) as typeof fetch
|
|
52
|
+
|
|
53
|
+
await extractPostEngagementHandler({ post_url: POST_URL, type: 'comments' })
|
|
54
|
+
expect(contactsUrl).toContain('activity_filter=commenters')
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
it('returns an error when the task fails upstream', async () => {
|
|
58
|
+
globalThis.fetch = vi.fn(async (url: string | URL) => {
|
|
59
|
+
const u = url.toString()
|
|
60
|
+
if (u.includes('/jungler/tasks/task_1/status')) return res({ task_id: 'task_1', status: 'failure' })
|
|
61
|
+
if (u.includes('/jungler/workbooks')) return res({ task_id: 'task_1', status: 'PENDING' })
|
|
62
|
+
throw new Error(`unexpected url ${u}`)
|
|
63
|
+
}) as typeof fetch
|
|
64
|
+
|
|
65
|
+
const result = await extractPostEngagementHandler({ post_url: POST_URL, type: 'both' })
|
|
66
|
+
expect(result.isError).toBe(true)
|
|
67
|
+
expect(JSON.parse(result.content[0].text).error).toMatch(/extraction failed/i)
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
it('surfaces the create error when the job cannot start', async () => {
|
|
71
|
+
globalThis.fetch = vi.fn(async () => res({ error: 'Invalid LinkedIn post URL' }, 400)) as typeof fetch
|
|
72
|
+
const result = await extractPostEngagementHandler({ post_url: POST_URL, type: 'both' })
|
|
73
|
+
expect(result.isError).toBe(true)
|
|
74
|
+
expect(JSON.parse(result.content[0].text).error).toMatch(/Invalid LinkedIn post URL/)
|
|
75
|
+
})
|
|
76
|
+
})
|
|
@@ -139,14 +139,17 @@ describe('find_signals handler — hiring industries post-filter', () => {
|
|
|
139
139
|
expect(body.data.data.map((r: { id: string }) => r.id)).toEqual(['a', 'c'])
|
|
140
140
|
})
|
|
141
141
|
|
|
142
|
-
it('returns
|
|
142
|
+
it('returns unfiltered rows with a note when no row matches any industry', async () => {
|
|
143
143
|
vi.mocked(mockedExecute).mockResolvedValueOnce({
|
|
144
144
|
data: { success: true, data: [...hiringRows] },
|
|
145
145
|
_meta: { provider: 'signalbase-hiring', latencyMs: 1 },
|
|
146
146
|
})
|
|
147
147
|
const result = await findSignalsHandler({ signal_type: 'hiring', industries: ['Aerospace'], limit: 25 })
|
|
148
148
|
const body = JSON.parse(result.content[0].text)
|
|
149
|
-
|
|
149
|
+
// Non-destructive: keep all rows rather than return a misleading empty set,
|
|
150
|
+
// and flag that the industry filter matched nothing.
|
|
151
|
+
expect(body.data.data).toHaveLength(5)
|
|
152
|
+
expect(body.data._industry_filter).toMatch(/UNFILTERED/)
|
|
150
153
|
})
|
|
151
154
|
|
|
152
155
|
it('leaves rows untouched when industries is absent', async () => {
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
|
2
|
+
import { initClient } from '../../src/client.js'
|
|
3
|
+
import { getPlaceReviewsHandler } from '../../src/tools/get-place-reviews.js'
|
|
4
|
+
import { getProviders } from '../../src/registry.js'
|
|
5
|
+
|
|
6
|
+
const PLACE_URL = 'https://www.google.com/maps/place/ColdIQ'
|
|
7
|
+
|
|
8
|
+
describe('get_place_reviews handler', () => {
|
|
9
|
+
const originalFetch = globalThis.fetch
|
|
10
|
+
|
|
11
|
+
beforeEach(() => {
|
|
12
|
+
initClient('http://test-api.local', 'test-key')
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
afterEach(() => {
|
|
16
|
+
globalThis.fetch = originalFetch
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
function withFastPolling<T>(fn: () => Promise<T>): Promise<T> {
|
|
20
|
+
const p = getProviders('get_place_reviews').find((x) => x.id === 'google_maps_reviews')!
|
|
21
|
+
const orig = { t: p.async!.timeoutMs, i: p.async!.pollIntervalMs }
|
|
22
|
+
p.async!.timeoutMs = 500
|
|
23
|
+
p.async!.pollIntervalMs = 20
|
|
24
|
+
return fn().finally(() => {
|
|
25
|
+
p.async!.timeoutMs = orig.t
|
|
26
|
+
p.async!.pollIntervalMs = orig.i
|
|
27
|
+
})
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
it('submits the reviews job and returns reviews when done', async () => {
|
|
31
|
+
const reviews = [{ text: 'Great service', stars: 5 }]
|
|
32
|
+
globalThis.fetch = vi.fn(async (url: string | URL) => {
|
|
33
|
+
const u = url.toString()
|
|
34
|
+
if (u.includes('/google-maps/reviews/rev-1')) {
|
|
35
|
+
return new Response(JSON.stringify({ jobId: 'rev-1', status: 'done', reviews }), { status: 200 })
|
|
36
|
+
}
|
|
37
|
+
if (u.includes('/google-maps/reviews')) {
|
|
38
|
+
return new Response(JSON.stringify({ jobId: 'rev-1', status: 'processing' }), { status: 200 })
|
|
39
|
+
}
|
|
40
|
+
return new Response(JSON.stringify({ error: 'unexpected' }), { status: 500 })
|
|
41
|
+
}) as typeof fetch
|
|
42
|
+
|
|
43
|
+
const result = await withFastPolling(() =>
|
|
44
|
+
getPlaceReviewsHandler({ place_urls: [PLACE_URL], max_reviews: 10, sort: 'lowestRanking' }),
|
|
45
|
+
)
|
|
46
|
+
expect(result.isError).toBeFalsy()
|
|
47
|
+
const parsed = JSON.parse(result.content[0].text)
|
|
48
|
+
expect(parsed._meta.provider).toBe('google_maps_reviews')
|
|
49
|
+
expect(parsed.data.reviews).toEqual(reviews)
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
it('forwards place_urls, max_reviews and sort to the upstream body', async () => {
|
|
53
|
+
let submitBody: Record<string, unknown> = {}
|
|
54
|
+
globalThis.fetch = vi.fn(async (url: string | URL, init?: RequestInit) => {
|
|
55
|
+
const u = url.toString()
|
|
56
|
+
if (u.includes('/google-maps/reviews/rev-1')) {
|
|
57
|
+
return new Response(JSON.stringify({ jobId: 'rev-1', status: 'done', reviews: [] }), { status: 200 })
|
|
58
|
+
}
|
|
59
|
+
if (u.includes('/google-maps/reviews')) {
|
|
60
|
+
submitBody = JSON.parse(init!.body as string)
|
|
61
|
+
return new Response(JSON.stringify({ jobId: 'rev-1', status: 'processing' }), { status: 200 })
|
|
62
|
+
}
|
|
63
|
+
return new Response(JSON.stringify({ error: 'unexpected' }), { status: 500 })
|
|
64
|
+
}) as typeof fetch
|
|
65
|
+
|
|
66
|
+
await withFastPolling(() =>
|
|
67
|
+
getPlaceReviewsHandler({ place_urls: [PLACE_URL], max_reviews: 25, sort: 'newest' }),
|
|
68
|
+
)
|
|
69
|
+
expect(submitBody.startUrls).toEqual([{ url: PLACE_URL }])
|
|
70
|
+
expect(submitBody.maxReviews).toBe(25)
|
|
71
|
+
expect(submitBody.reviewsSort).toBe('newest')
|
|
72
|
+
})
|
|
73
|
+
})
|
|
@@ -122,4 +122,73 @@ describe('search_reddit handler', () => {
|
|
|
122
122
|
reddit.async!.pollIntervalMs = orig.i
|
|
123
123
|
}
|
|
124
124
|
})
|
|
125
|
+
|
|
126
|
+
it('rewrites a bare subreddit URL + query into an in-subreddit search URL', async () => {
|
|
127
|
+
let capturedBody: Record<string, unknown> | null = null
|
|
128
|
+
globalThis.fetch = vi.fn(async (url: string | URL | Request, opts?: RequestInit) => {
|
|
129
|
+
const u = url.toString()
|
|
130
|
+
if (u.includes('/reddit/scrape') && !u.includes('/scrape/')) {
|
|
131
|
+
capturedBody = JSON.parse((opts?.body as string) ?? '{}')
|
|
132
|
+
return new Response(JSON.stringify({ jobId: 'r-4' }), { status: 200 })
|
|
133
|
+
}
|
|
134
|
+
if (u.includes('/reddit/scrape/r-4')) {
|
|
135
|
+
return new Response(JSON.stringify({ jobId: 'r-4', status: 'done', items: [{ title: 'lemlist review' }] }), { status: 200 })
|
|
136
|
+
}
|
|
137
|
+
return new Response(JSON.stringify({ error: 'unexpected' }), { status: 500 })
|
|
138
|
+
}) as typeof fetch
|
|
139
|
+
|
|
140
|
+
const { getProviders } = await import('../../src/registry.js')
|
|
141
|
+
const reddit = getProviders('search_reddit').find((p) => p.id === 'reddit')!
|
|
142
|
+
const orig = { t: reddit.async!.timeoutMs, i: reddit.async!.pollIntervalMs }
|
|
143
|
+
try {
|
|
144
|
+
reddit.async!.timeoutMs = 500
|
|
145
|
+
reddit.async!.pollIntervalMs = 20
|
|
146
|
+
await searchRedditHandler({
|
|
147
|
+
start_urls: ['https://www.reddit.com/r/coldemail/'],
|
|
148
|
+
query: 'lemlist',
|
|
149
|
+
time: 'month',
|
|
150
|
+
limit: 5,
|
|
151
|
+
})
|
|
152
|
+
const urls = (capturedBody!.startUrls as Array<{ url: string }>).map((s) => s.url)
|
|
153
|
+
expect(urls[0]).toContain('/r/coldemail/search/')
|
|
154
|
+
expect(urls[0]).toContain('q=lemlist')
|
|
155
|
+
expect(urls[0]).toContain('restrict_sr=1')
|
|
156
|
+
expect(urls[0]).toContain('t=month')
|
|
157
|
+
// Query now lives in the URL — top-level searchQueries is dropped to avoid a global search.
|
|
158
|
+
expect(capturedBody!.searchQueries).toBeUndefined()
|
|
159
|
+
} finally {
|
|
160
|
+
reddit.async!.timeoutMs = orig.t
|
|
161
|
+
reddit.async!.pollIntervalMs = orig.i
|
|
162
|
+
}
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
it('leaves an existing search URL untouched and keeps it as the start url', async () => {
|
|
166
|
+
let capturedBody: Record<string, unknown> | null = null
|
|
167
|
+
const searchUrl = 'https://www.reddit.com/search/?q=lemlist&sort=new'
|
|
168
|
+
globalThis.fetch = vi.fn(async (url: string | URL | Request, opts?: RequestInit) => {
|
|
169
|
+
const u = url.toString()
|
|
170
|
+
if (u.includes('/reddit/scrape') && !u.includes('/scrape/')) {
|
|
171
|
+
capturedBody = JSON.parse((opts?.body as string) ?? '{}')
|
|
172
|
+
return new Response(JSON.stringify({ jobId: 'r-5' }), { status: 200 })
|
|
173
|
+
}
|
|
174
|
+
if (u.includes('/reddit/scrape/r-5')) {
|
|
175
|
+
return new Response(JSON.stringify({ jobId: 'r-5', status: 'done', items: [{ title: 'x' }] }), { status: 200 })
|
|
176
|
+
}
|
|
177
|
+
return new Response(JSON.stringify({ error: 'unexpected' }), { status: 500 })
|
|
178
|
+
}) as typeof fetch
|
|
179
|
+
|
|
180
|
+
const { getProviders } = await import('../../src/registry.js')
|
|
181
|
+
const reddit = getProviders('search_reddit').find((p) => p.id === 'reddit')!
|
|
182
|
+
const orig = { t: reddit.async!.timeoutMs, i: reddit.async!.pollIntervalMs }
|
|
183
|
+
try {
|
|
184
|
+
reddit.async!.timeoutMs = 500
|
|
185
|
+
reddit.async!.pollIntervalMs = 20
|
|
186
|
+
await searchRedditHandler({ start_urls: [searchUrl], query: 'lemlist', limit: 5 })
|
|
187
|
+
const urls = (capturedBody!.startUrls as Array<{ url: string }>).map((s) => s.url)
|
|
188
|
+
expect(urls[0]).toBe(searchUrl)
|
|
189
|
+
} finally {
|
|
190
|
+
reddit.async!.timeoutMs = orig.t
|
|
191
|
+
reddit.async!.pollIntervalMs = orig.i
|
|
192
|
+
}
|
|
193
|
+
})
|
|
125
194
|
})
|