Skip to content

Commit 1b09ca0

Browse files
committed
Handle bare URL activity suggestions
1 parent 5a9637b commit 1b09ca0

5 files changed

Lines changed: 170 additions & 9 deletions

File tree

src/classifier/index.test.ts

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { beforeEach, describe, expect, it, vi } from 'vitest'
2+
import type { ScrapedMetadata } from '../scraper/types'
23
import { createActivity as createTestActivity } from '../test-support'
34
import type { CandidateMessage } from '../types'
45

@@ -271,6 +272,67 @@ describe('Classifier Module', () => {
271272
}
272273
})
273274

275+
it('classifies a bare website URL using scraped OG metadata', async () => {
276+
const url = 'https://www.omata.co.nz/kitchen'
277+
const metadata: ScrapedMetadata = {
278+
canonicalUrl: url,
279+
contentId: null,
280+
title: 'Kitchen — Omata Estate',
281+
description: 'A relaxed style eatery with views overlooking the ocean.',
282+
hashtags: [],
283+
creator: null,
284+
imageUrl: null,
285+
categories: ['omata.co.nz'],
286+
suggestedKeywords: []
287+
}
288+
mockFetch.mockResolvedValue({
289+
ok: true,
290+
json: async () => ({
291+
content: [
292+
{
293+
type: 'text',
294+
text: createMockClassifierResponse([
295+
{
296+
message_id: 1,
297+
activity: 'Go to Omata Estate',
298+
category: 'food',
299+
confidence: 0.95,
300+
city: 'Russell',
301+
country: 'New Zealand',
302+
action: 'go'
303+
}
304+
])
305+
}
306+
]
307+
})
308+
})
309+
310+
const candidates: CandidateMessage[] = [
311+
{
312+
...createCandidate(1, url),
313+
source: { type: 'url', urlType: 'website' },
314+
confidence: 0.55,
315+
urls: [url]
316+
}
317+
]
318+
319+
const result = await classifyMessages(candidates, {
320+
...BASE_CONFIG,
321+
provider: 'anthropic',
322+
apiKey: 'test-key',
323+
urlMetadata: new Map([[url, metadata]])
324+
})
325+
326+
expect(result.ok).toBe(true)
327+
if (!result.ok) throw new Error(result.error.message)
328+
expect(result.value.activities[0]?.activity).toBe('Go to Omata Estate')
329+
330+
const call = mockFetch.mock.calls[0] as [string, { body: string }]
331+
const body = JSON.parse(call[1].body) as { messages: Array<{ content: string }> }
332+
expect(body.messages[0]?.content).toContain('[URL_META:')
333+
expect(body.messages[0]?.content).toContain('Kitchen — Omata Estate')
334+
})
335+
274336
it('handles HTTP errors', async () => {
275337
mockFetch.mockResolvedValue({
276338
ok: false,

src/extraction/heuristics/index.test.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -250,6 +250,33 @@ Sun, 26/04/2026
250250

251251
expect(result.candidates).toHaveLength(1)
252252
})
253+
254+
it('matches a bare generic website URL so OG metadata can be scraped', () => {
255+
const url = 'https://www.omata.co.nz/kitchen'
256+
const messages = [createMessage(0, url, 'User', [url])]
257+
258+
const result = extractCandidatesByHeuristics(messages)
259+
260+
expect(result.urlMatches).toBe(1)
261+
expect(result.candidates).toEqual([
262+
expect.objectContaining({
263+
content: url,
264+
confidence: 0.55,
265+
source: { type: 'url', urlType: 'website' },
266+
urls: [url]
267+
})
268+
])
269+
})
270+
271+
it('does not match bare reference URLs without activity context', () => {
272+
const url = 'https://en.wikipedia.org/wiki/The_Matrix'
273+
const messages = [createMessage(0, url, 'User', [url])]
274+
275+
const result = extractCandidatesByHeuristics(messages)
276+
277+
expect(result.candidates).toHaveLength(0)
278+
expect(result.urlMatches).toBe(0)
279+
})
253280
})
254281

255282
describe('exclusion patterns', () => {

src/extraction/heuristics/index.ts

Lines changed: 42 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,8 @@ export {
4949
const DEFAULT_MIN_CONFIDENCE = 0.5
5050
const ACTIVITY_KEYWORD_BOOST = 0.15
5151
const URL_SUGGESTION_BOOST = 0.25
52+
const BARE_WEBSITE_URL_CONFIDENCE = 0.55
53+
const REFERENCE_WEBSITE_DOMAINS = ['wikipedia.org', 'wikimedia.org']
5254

5355
/**
5456
* Check if content contains activity-related keywords.
@@ -225,15 +227,49 @@ function findBestUrl(urls: readonly string[]): BestUrl {
225227
return { type: bestType, confidence: bestConfidence }
226228
}
227229

228-
function shouldIncludeUrl(firstUrl: string, content: string): boolean {
230+
function isBareUrlMessage(content: string, urls: readonly string[]): boolean {
231+
let remaining = content
232+
for (const url of urls) {
233+
remaining = remaining.replaceAll(url, ' ')
234+
}
235+
236+
return remaining.replace(/[\s.,;:!?()[\]{}'"<>-]/g, '').length === 0
237+
}
238+
239+
function isReferenceWebsiteUrl(url: string): boolean {
240+
try {
241+
const hostname = new URL(url).hostname.toLowerCase()
242+
return REFERENCE_WEBSITE_DOMAINS.some(
243+
(domain) => hostname === domain || hostname.endsWith(`.${domain}`)
244+
)
245+
} catch {
246+
return false
247+
}
248+
}
249+
250+
function shouldIncludeUrl(firstUrl: string, content: string, urls: readonly string[]): boolean {
229251
// Skip social media URLs - they could be anything (memes, random videos)
230252
if (isSocialUrl(firstUrl)) return false
231253
// Include activity URLs or messages with activity phrases
232-
return isActivityUrl(firstUrl) || hasActivityPhrase(content)
254+
return (
255+
isActivityUrl(firstUrl) ||
256+
hasActivityPhrase(content) ||
257+
(classifyUrl(firstUrl) === 'website' &&
258+
!isReferenceWebsiteUrl(firstUrl) &&
259+
isBareUrlMessage(content, urls))
260+
)
233261
}
234262

235-
function applyUrlBoosts(confidence: number, content: string): number {
263+
function applyUrlBoosts(
264+
confidence: number,
265+
content: string,
266+
urlType: string,
267+
urls: readonly string[]
268+
): number {
236269
let result = confidence
270+
if (urlType === 'website' && isBareUrlMessage(content, urls)) {
271+
result = Math.max(result, BARE_WEBSITE_URL_CONFIDENCE)
272+
}
237273
if (hasActivityPhrase(content)) {
238274
result = Math.min(1.0, result + URL_SUGGESTION_BOOST)
239275
}
@@ -262,10 +298,10 @@ function findUrlMatches(
262298
if (!msg || !msg.urls || msg.urls.length === 0) continue
263299

264300
const firstUrl = msg.urls[0] ?? ''
265-
if (!shouldIncludeUrl(firstUrl, msg.content)) continue
301+
if (!shouldIncludeUrl(firstUrl, msg.content, msg.urls)) continue
266302

267303
const best = findBestUrl(msg.urls)
268-
const confidence = applyUrlBoosts(best.confidence, msg.content)
304+
const confidence = applyUrlBoosts(best.confidence, msg.content, best.type, msg.urls)
269305

270306
if (confidence >= minConfidence) {
271307
const ctx = getMessageContext(messages, i)
@@ -276,7 +312,7 @@ function findUrlMatches(
276312
timestamp: msg.timestamp,
277313
confidence,
278314
urlType: best.type,
279-
candidateType: 'suggestion', // Sharing a URL = suggesting
315+
candidateType: 'suggestion',
280316
urls: msg.urls,
281317
contextBefore: ctx.before,
282318
contextAfter: ctx.after

src/scraper/metadata.test.ts

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,8 @@
44

55
import { describe, expect, it } from 'vitest'
66
import type { CandidateMessage } from '../types'
7-
import { extractUrlsFromCandidates, extractUrlsFromText } from './metadata'
7+
import { extractUrlsFromCandidates, extractUrlsFromText, fetchMetadataForUrls } from './metadata'
8+
import type { FetchFn } from './types'
89

910
describe('extractUrlsFromText', () => {
1011
it('extracts http URLs', () => {
@@ -93,3 +94,38 @@ describe('extractUrlsFromCandidates', () => {
9394
expect(extractUrlsFromCandidates(candidates)).toEqual(['https://a.com', 'https://b.com'])
9495
})
9596
})
97+
98+
describe('fetchMetadataForUrls', () => {
99+
it('fetches OG metadata for a bare Omata Kitchen URL', async () => {
100+
const url = 'https://www.omata.co.nz/kitchen'
101+
const fetch: FetchFn = async () => ({
102+
ok: true,
103+
status: 200,
104+
headers: { get: () => null },
105+
text: async () => `
106+
<html>
107+
<head>
108+
<meta property="og:title" content="Kitchen &mdash; Omata Estate">
109+
<meta
110+
property="og:description"
111+
content="A relaxed style eatery with views overlooking the ocean."
112+
>
113+
<meta property="og:url" content="${url}">
114+
</head>
115+
</html>
116+
`,
117+
json: async () => ({}),
118+
arrayBuffer: async () => new ArrayBuffer(0)
119+
})
120+
121+
const metadata = await fetchMetadataForUrls([url], { fetch, concurrency: 1 })
122+
123+
expect(metadata.get(url)).toEqual(
124+
expect.objectContaining({
125+
canonicalUrl: url,
126+
title: 'Kitchen — Omata Estate',
127+
description: 'A relaxed style eatery with views overlooking the ocean.'
128+
})
129+
)
130+
})
131+
})
Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
version https://git-lfs.github.com/spec/v1
2-
oid sha256:2847fa37aded18d0661d9c2b3fe913cb21416544e2cdce074974fd032026bb07
3-
size 16840445
2+
oid sha256:10a5b46602cbb4835b4b4ba895c55c22112c689014607d5b016d9700030051ac
3+
size 18459094

0 commit comments

Comments
 (0)