-
Notifications
You must be signed in to change notification settings - Fork 106
Add home-snipe - Mino Use Case #14
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Conversation
- **Live Demo:** https://home-snipe.vercel.app - Live demo: https://home-snipe.vercel.app - Contributor: Pranav Janakiraman (@pranavjana)
📝 WalkthroughWalkthroughThis PR adds a complete Next.js app ("home-snipe") with a streaming, multi-phase HDB search flow: a client UI that initiates searches and consumes SSE-like streamed events; a new POST API route (/api/search) that coordinates Phase 1 (URL generation via Gemini or fallback), Phase 2 (parallel scraping via Mino automation), and Phase 3 (analysis via Gemini or fallback); reusable Mino SSE client and utilities; a collection of UI components and global styling; and standard project config files (tsconfig, package.json, Next config, ESLint, PostCSS). All progress and results are streamed to the client as SSE-formatted chunks. Sequence Diagram(s)sequenceDiagram
actor User
participant Client as Browser Client
participant API as /api/search Route
participant Gemini as Gemini API
participant Mino as Mino API
participant Scraper as Scraper Agents
User->>Client: Submit search (town, flatType, minDiscount)
Client->>API: POST /api/search (starts SSE stream)
rect rgba(100,150,200,0.5)
Note over API: Phase 1 — Generate URLs
API->>Gemini: Prompt for top property URLs
Gemini-->>API: URL list (or fails)
API->>API: validate URLs / fallback to hardcoded list
API->>Client: GENERATING_URLS events (stream URLs)
end
rect rgba(150,100,200,0.5)
Note over API: Phase 2 — Parallel scraping
API->>Mino: Launch scraper automations (one per URL)
par Scraping in parallel
Mino->>Scraper: Scrape URL (agent)
Scraper-->>Mino: STEP / RESULT events
Mino-->>API: stream events (AGENT_STATUS, AGENT_STEP, partial results)
end
API->>Client: SCRAPING progress events (per-agent updates)
end
rect rgba(200,150,100,0.5)
Note over API: Phase 3 — Analyze listings
API->>Gemini: Send aggregated listings for deal analysis
Gemini-->>API: DEALS JSON (or fails)
API->>Client: ANALYZING and DEALS_FOUND events
end
API->>Client: COMPLETE event (stream closed)
Client->>User: Render live agents, listings, and deals
🚥 Pre-merge checks | ✅ 2✅ Passed checks (2 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. Comment |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Actionable comments posted: 9
🤖 Fix all issues with AI agents
In `@home-snipe/app/api/search/route.ts`:
- Around line 171-177: discountThreshold from SearchParams is not validated;
update the handler that reads const { town, flatType, discountThreshold }:
SearchParams = await request.json() to validate and normalize discountThreshold
before using it (e.g., ensure it's a number, not NaN, and within a reasonable
range like 0–100), sendEvent an ERROR and controller.close() if invalid or
coerce to a safe default; reference the SearchParams destructuring, the
discountThreshold variable, request.json(), and the existing
sendEvent/controller.close() flow when implementing the checks.
- Around line 147-149: Replace the empty catch in the JSON parsing block in
route.ts with a caught error variable (e.g., catch (err)) and distinguish JSON
parse errors from other exceptions: if err is a SyntaxError (or matches
JSON.parse failure) silently ignore or handle as before, otherwise log the error
(using the existing logger or console.error) or rethrow so unexpected issues are
visible; update the catch around the JSON.parse/parse logic accordingly and
reference the same parsing block in route.ts when applying the change.
- Around line 15-38: The callGemini function can hang because fetch has no
timeout; wrap the request with an AbortController in callGemini, create a
timeout (e.g., from a GEMINI_TIMEOUT_MS env var or a sensible default like
10_000ms), pass controller.signal into fetch, clear the timer on success, and
call controller.abort() on timeout so the fetch rejects with an AbortError;
ensure the existing error handling treats an aborted request as a timeout error
and includes that context in the thrown error.
In `@home-snipe/app/globals.css`:
- Around line 54-70: The `@theme` inline block contains self-referential CSS
custom properties (e.g., --tracking-normal, --shadow-2xl, --shadow-xl,
--shadow-lg, --shadow-md, --shadow, --shadow-sm, --shadow-xs, --shadow-2xs,
--spacing, --letter-spacing, --shadow-offset-y, --shadow-offset-x,
--shadow-spread, --shadow-blur, --shadow-opacity, --color-shadow-color) that
reference themselves via var(--name); remove these circular declarations from
the `@theme` inline block (or replace them with concrete values if the theme must
override them) so the variables fall back to the intended definitions in :root
and no longer resolve to empty/initial values.
In `@home-snipe/app/page.tsx`:
- Around line 665-667: The JSX uses new URL(agent.url) which can throw on
malformed or empty strings; wrap parsing in a safe helper (e.g.,
getHostname(url: string)) that tries new URL(url).hostname.replace('www.', '')
inside a try/catch and returns a fallback (like the original url or empty
string) on error, then replace the inline new URL(agent.url).hostname...
expression with a call to getHostname(agent.url) in the component render.
- Around line 154-175: The fetch in handleSearch should be cancellable to
prevent updates after unmount or when a new search starts: create an
AbortController for each handleSearch invocation (store the current controller
in a ref), pass controller.signal to the fetch call in handleSearch, and call
controller.abort() before starting a new search; also add a useEffect cleanup
that aborts the active controller on component unmount. In the fetch error
handling, detect an aborted request (error.name === "AbortError") and skip
calling setters like setPhase, setStatusMessage, setAgents, setRawListings,
setDeals, and setError to avoid state updates after abort. Ensure the unique
symbols referenced are handleSearch, the fetch invocation inside it, and the
state setters used after the fetch.
In `@home-snipe/lib/mino-client.ts`:
- Around line 166-171: The code unsafely asserts options.proxy into the
country_code union when building config.proxy_config; instead validate
options.proxy against the allowed country codes before assigning it. In the
block that sets config.proxy_config (referencing config.proxy_config and
options.proxy in mino-client.ts), check that options.proxy is one of
["US","GB","CA","DE","FR","JP","AU"] (or derive this set from the union/type
guard), and only set country_code if it matches; otherwise omit proxy_config or
throw/log a clear error so invalid strings are not sent to the API.
- Around line 48-56: The fetch to MINO_API_URL in mino-client.ts can hang
indefinitely; wrap it with an AbortController and a timer (e.g., 60–120s) and
pass controller.signal into fetch (the POST block where headers/body are set) so
the request is aborted on timeout; store the timeout id, clearTimeout on
successful response before returning, and also clearTimeout and call
controller.abort() in the catch/finally path to ensure the timer is cleaned up
and the request is cancelled on errors/timeouts.
In `@home-snipe/README.md`:
- Line 3: Replace the bare URL after the "**Live Demo:**" text with a Markdown
link so the README uses proper link syntax and avoids MD034; specifically change
the string "**Live Demo:** https://home-snipe.vercel.app" to use a link like
"**Live Demo:** [Live Demo](https://home-snipe.vercel.app)" (or similar
descriptive link text) to keep formatting consistent.
🧹 Nitpick comments (9)
home-snipe/components/ui/background-snippets.tsx (1)
1-4: Consider marking the decorative background as non-interactive.Keeps it out of the accessibility tree and prevents accidental event capture.
♻️ Suggested tweak
- <div className="fixed inset-0 -z-10 h-full w-full bg-background" /> + <div + className="fixed inset-0 -z-10 h-full w-full bg-background pointer-events-none" + aria-hidden="true" + />home-snipe/components/ui/radio-group.tsx (1)
9-43: Consider aligning ref forwarding with established component patterns.This recommendation conflicts with the codebase's architectural approach. Other UI wrapper components (Button, Input, Select) do not use
forwardRef—instead, they follow the Radix pattern of composition via props and theSlot/asChildapproach. If ref forwarding is needed for RadioGroup, the entire component library should be updated for consistency, rather than introducingforwardRefin isolation. No current usages of these components with refs were found, so this is best evaluated as part of a broader architecture discussion.home-snipe/components/ui/background-gradient.tsx (1)
54-60: Consider using CSS variables for hardcoded colors.The components use hardcoded hex colors (
#d1d5db33,#38bdf8,bg-emerald-400, etc.) whileBackgroundGradientuses CSS variables likehsl(var(--primary)). For theme consistency and dark mode support, consider using CSS variables throughout.home-snipe/app/api/search/route.ts (2)
192-212: Consider sanitizing user input in prompts.User-provided
townandflatTypevalues are directly interpolated into the Gemini prompt. While the impact is mitigated by URL validation on the output, consider basic sanitization to prevent prompt injection attempts.
41-159: Duplicate SSE parsing logic withmino-client.ts.This
scrapeWithMinofunction duplicates the SSE streaming logic fromhome-snipe/lib/mino-client.ts. While this version adds real-time event forwarding viasendEvent, consider extracting the common SSE parsing into a shared utility to reduce duplication.home-snipe/app/page.tsx (4)
128-152: Potential stale closure issue withagentsdependency.The
extractListingscallback referencesagentsfrom its closure, but when called from theAGENT_COMPLETEhandler, theagentsstate may not yet reflect the latest updates. This could result insourcebeing undefined or showing incorrect data.Consider passing the URL directly from the event payload instead of looking it up from state:
Suggested fix
The API should include the URL in the
AGENT_COMPLETEevent, or you can restructure to avoid the dependency:- const extractListings = useCallback((result: unknown, agentId: number): RawListing[] => { + const extractListings = useCallback((result: unknown, agentId: number, agentUrl?: string): RawListing[] => { let listings: Array<Record<string, unknown>> = []; // ... extraction logic ... return listings.map(l => ({ ...l, agentId, - source: agents[agentId]?.url ? new URL(agents[agentId].url).hostname : `Agent ${agentId + 1}`, + source: agentUrl ? new URL(agentUrl).hostname : `Agent ${agentId + 1}`, })); - }, [agents]); + }, []);Then update the call site to pass the URL from the event or from a ref that tracks agents.
290-293: Silent catch may hide server-side issues.Silently ignoring JSON parse errors makes debugging difficult when the server sends malformed events. Consider logging in development mode.
Suggested improvement
} catch { - // Ignore parse errors + // Log parse errors in development for debugging + if (process.env.NODE_ENV === 'development') { + console.warn('Failed to parse SSE event:', line); + } }
598-604: Consider addingsandboxattribute to iframes for defense-in-depth.The iframes load external streaming URLs. While these come from your server API, adding a
sandboxattribute provides an extra layer of protection if the streaming service were compromised.Suggested improvement
<iframe src={agent.streamingUrl} className="w-full h-full border-0" allow="autoplay; fullscreen" allowFullScreen + sandbox="allow-scripts allow-same-origin" />Apply the same to the dialog iframe at line 869-874.
571-594: Consider adding accessibility attributes to clickable agent cards.The agent cards are clickable (
onClick) but lack semantic button role or accessible labels. Screen reader users won't understand these are interactive.Suggested improvement
<motion.div key={agent.id} + role="button" + tabIndex={0} + aria-label={`View details for Agent ${agent.id + 1}, status: ${agent.status}`} + onKeyDown={(e) => e.key === 'Enter' && setSelectedAgent(agent)} variants={{...}} // ... onClick={() => setSelectedAgent(agent)} >
| async function callGemini(prompt: string): Promise<string> { | ||
| const apiKey = process.env.GEMINI_API_KEY; | ||
| if (!apiKey) throw new Error("GEMINI_API_KEY not configured"); | ||
|
|
||
| const response = await fetch(`${GEMINI_API_URL}?key=${apiKey}`, { | ||
| method: "POST", | ||
| headers: { "Content-Type": "application/json" }, | ||
| body: JSON.stringify({ | ||
| contents: [{ parts: [{ text: prompt }] }], | ||
| generationConfig: { | ||
| temperature: 0.7, | ||
| maxOutputTokens: 4096, | ||
| }, | ||
| }), | ||
| }); | ||
|
|
||
| if (!response.ok) { | ||
| const error = await response.text(); | ||
| throw new Error(`Gemini API error: ${response.status} ${error}`); | ||
| } | ||
|
|
||
| const data = await response.json(); | ||
| return data.candidates?.[0]?.content?.parts?.[0]?.text || ""; | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Add timeout to external API calls to prevent indefinite hangs.
The callGemini function lacks a timeout. If the Gemini API becomes unresponsive, the request will hang until the 5-minute maxDuration is exhausted, tying up resources.
🔧 Proposed fix using AbortController
async function callGemini(prompt: string): Promise<string> {
const apiKey = process.env.GEMINI_API_KEY;
if (!apiKey) throw new Error("GEMINI_API_KEY not configured");
+ const controller = new AbortController();
+ const timeoutId = setTimeout(() => controller.abort(), 30000); // 30s timeout
+
- const response = await fetch(`${GEMINI_API_URL}?key=${apiKey}`, {
+ const response = await fetch(`${GEMINI_API_URL}?key=${apiKey}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
contents: [{ parts: [{ text: prompt }] }],
generationConfig: {
temperature: 0.7,
maxOutputTokens: 4096,
},
}),
+ signal: controller.signal,
});
+
+ clearTimeout(timeoutId);📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| async function callGemini(prompt: string): Promise<string> { | |
| const apiKey = process.env.GEMINI_API_KEY; | |
| if (!apiKey) throw new Error("GEMINI_API_KEY not configured"); | |
| const response = await fetch(`${GEMINI_API_URL}?key=${apiKey}`, { | |
| method: "POST", | |
| headers: { "Content-Type": "application/json" }, | |
| body: JSON.stringify({ | |
| contents: [{ parts: [{ text: prompt }] }], | |
| generationConfig: { | |
| temperature: 0.7, | |
| maxOutputTokens: 4096, | |
| }, | |
| }), | |
| }); | |
| if (!response.ok) { | |
| const error = await response.text(); | |
| throw new Error(`Gemini API error: ${response.status} ${error}`); | |
| } | |
| const data = await response.json(); | |
| return data.candidates?.[0]?.content?.parts?.[0]?.text || ""; | |
| } | |
| async function callGemini(prompt: string): Promise<string> { | |
| const apiKey = process.env.GEMINI_API_KEY; | |
| if (!apiKey) throw new Error("GEMINI_API_KEY not configured"); | |
| const controller = new AbortController(); | |
| const timeoutId = setTimeout(() => controller.abort(), 30000); // 30s timeout | |
| const response = await fetch(`${GEMINI_API_URL}?key=${apiKey}`, { | |
| method: "POST", | |
| headers: { "Content-Type": "application/json" }, | |
| body: JSON.stringify({ | |
| contents: [{ parts: [{ text: prompt }] }], | |
| generationConfig: { | |
| temperature: 0.7, | |
| maxOutputTokens: 4096, | |
| }, | |
| }), | |
| signal: controller.signal, | |
| }); | |
| clearTimeout(timeoutId); | |
| if (!response.ok) { | |
| const error = await response.text(); | |
| throw new Error(`Gemini API error: ${response.status} ${error}`); | |
| } | |
| const data = await response.json(); | |
| return data.candidates?.[0]?.content?.parts?.[0]?.text || ""; | |
| } |
🤖 Prompt for AI Agents
In `@home-snipe/app/api/search/route.ts` around lines 15 - 38, The callGemini
function can hang because fetch has no timeout; wrap the request with an
AbortController in callGemini, create a timeout (e.g., from a GEMINI_TIMEOUT_MS
env var or a sensible default like 10_000ms), pass controller.signal into fetch,
clear the timer on success, and call controller.abort() on timeout so the fetch
rejects with an AbortError; ensure the existing error handling treats an aborted
request as a timeout error and includes that context in the thrown error.
| } catch { | ||
| // Ignore parse errors | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Silent catch block hides potential issues.
The empty catch block at Line 147-149 swallows all errors including unexpected ones, making debugging difficult. Consider logging non-parse errors or at minimum distinguishing JSON parse errors from other exceptions.
🔧 Suggested improvement
- } catch {
- // Ignore parse errors
- }
+ } catch (e) {
+ // Only ignore JSON parse errors, log others
+ if (!(e instanceof SyntaxError)) {
+ console.error("Unexpected error processing SSE event:", e);
+ }
+ }📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| } catch { | |
| // Ignore parse errors | |
| } | |
| } catch (e) { | |
| // Only ignore JSON parse errors, log others | |
| if (!(e instanceof SyntaxError)) { | |
| console.error("Unexpected error processing SSE event:", e); | |
| } | |
| } |
🤖 Prompt for AI Agents
In `@home-snipe/app/api/search/route.ts` around lines 147 - 149, Replace the empty
catch in the JSON parsing block in route.ts with a caught error variable (e.g.,
catch (err)) and distinguish JSON parse errors from other exceptions: if err is
a SyntaxError (or matches JSON.parse failure) silently ignore or handle as
before, otherwise log the error (using the existing logger or console.error) or
rethrow so unexpected issues are visible; update the catch around the
JSON.parse/parse logic accordingly and reference the same parsing block in
route.ts when applying the change.
| const { town, flatType, discountThreshold }: SearchParams = await request.json(); | ||
|
|
||
| if (!town || !flatType) { | ||
| sendEvent({ type: "ERROR", message: "Town and flat type are required" }); | ||
| controller.close(); | ||
| return; | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Validate discountThreshold parameter.
discountThreshold is destructured but not validated. If it's undefined, NaN, or an unreasonable value (e.g., negative or > 100), the analysis prompt will contain invalid data.
🔧 Proposed fix
const { town, flatType, discountThreshold }: SearchParams = await request.json();
- if (!town || !flatType) {
- sendEvent({ type: "ERROR", message: "Town and flat type are required" });
+ const threshold = Number(discountThreshold);
+ if (!town || !flatType || isNaN(threshold) || threshold < 0 || threshold > 100) {
+ sendEvent({ type: "ERROR", message: "Valid town, flat type, and discount threshold (0-100) are required" });
controller.close();
return;
}📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| const { town, flatType, discountThreshold }: SearchParams = await request.json(); | |
| if (!town || !flatType) { | |
| sendEvent({ type: "ERROR", message: "Town and flat type are required" }); | |
| controller.close(); | |
| return; | |
| } | |
| const { town, flatType, discountThreshold }: SearchParams = await request.json(); | |
| const threshold = Number(discountThreshold); | |
| if (!town || !flatType || isNaN(threshold) || threshold < 0 || threshold > 100) { | |
| sendEvent({ type: "ERROR", message: "Valid town, flat type, and discount threshold (0-100) are required" }); | |
| controller.close(); | |
| return; | |
| } |
🤖 Prompt for AI Agents
In `@home-snipe/app/api/search/route.ts` around lines 171 - 177, discountThreshold
from SearchParams is not validated; update the handler that reads const { town,
flatType, discountThreshold }: SearchParams = await request.json() to validate
and normalize discountThreshold before using it (e.g., ensure it's a number, not
NaN, and within a reasonable range like 0–100), sendEvent an ERROR and
controller.close() if invalid or coerce to a safe default; reference the
SearchParams destructuring, the discountThreshold variable, request.json(), and
the existing sendEvent/controller.close() flow when implementing the checks.
| --tracking-normal: var(--tracking-normal); | ||
| --shadow-2xl: var(--shadow-2xl); | ||
| --shadow-xl: var(--shadow-xl); | ||
| --shadow-lg: var(--shadow-lg); | ||
| --shadow-md: var(--shadow-md); | ||
| --shadow: var(--shadow); | ||
| --shadow-sm: var(--shadow-sm); | ||
| --shadow-xs: var(--shadow-xs); | ||
| --shadow-2xs: var(--shadow-2xs); | ||
| --spacing: var(--spacing); | ||
| --letter-spacing: var(--letter-spacing); | ||
| --shadow-offset-y: var(--shadow-offset-y); | ||
| --shadow-offset-x: var(--shadow-offset-x); | ||
| --shadow-spread: var(--shadow-spread); | ||
| --shadow-blur: var(--shadow-blur); | ||
| --shadow-opacity: var(--shadow-opacity); | ||
| --color-shadow-color: var(--shadow-color); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Circular CSS variable references will cause unexpected behavior.
Several CSS custom properties in the @theme inline block reference themselves, creating invalid circular definitions that will resolve to initial/empty values instead of the intended values from :root:
--tracking-normal: var(--tracking-normal)(line 54)--shadow-*variables (lines 55-62)--spacing,--letter-spacing,--shadow-offset-*, etc. (lines 63-69)
These should either be removed from @theme inline (since they're already defined in :root) or assigned concrete values.
Suggested fix: Remove self-referential declarations
--radius-4xl: calc(var(--radius) + 16px);
--font-serif: Lora, serif;
--radius: 1.25rem;
- --tracking-tighter: calc(var(--tracking-normal) - 0.05em);
- --tracking-tight: calc(var(--tracking-normal) - 0.025em);
- --tracking-wide: calc(var(--tracking-normal) + 0.025em);
- --tracking-wider: calc(var(--tracking-normal) + 0.05em);
- --tracking-widest: calc(var(--tracking-normal) + 0.1em);
- --tracking-normal: var(--tracking-normal);
- --shadow-2xl: var(--shadow-2xl);
- --shadow-xl: var(--shadow-xl);
- --shadow-lg: var(--shadow-lg);
- --shadow-md: var(--shadow-md);
- --shadow: var(--shadow);
- --shadow-sm: var(--shadow-sm);
- --shadow-xs: var(--shadow-xs);
- --shadow-2xs: var(--shadow-2xs);
- --spacing: var(--spacing);
- --letter-spacing: var(--letter-spacing);
- --shadow-offset-y: var(--shadow-offset-y);
- --shadow-offset-x: var(--shadow-offset-x);
- --shadow-spread: var(--shadow-spread);
- --shadow-blur: var(--shadow-blur);
- --shadow-opacity: var(--shadow-opacity);
- --color-shadow-color: var(--shadow-color);
- --color-destructive-foreground: var(--destructive-foreground);
+ --tracking-normal: 0em;
+ --tracking-tighter: calc(var(--tracking-normal) - 0.05em);
+ --tracking-tight: calc(var(--tracking-normal) - 0.025em);
+ --tracking-wide: calc(var(--tracking-normal) + 0.025em);
+ --tracking-wider: calc(var(--tracking-normal) + 0.05em);
+ --tracking-widest: calc(var(--tracking-normal) + 0.1em);
+ --color-destructive-foreground: var(--destructive-foreground);
}🤖 Prompt for AI Agents
In `@home-snipe/app/globals.css` around lines 54 - 70, The `@theme` inline block
contains self-referential CSS custom properties (e.g., --tracking-normal,
--shadow-2xl, --shadow-xl, --shadow-lg, --shadow-md, --shadow, --shadow-sm,
--shadow-xs, --shadow-2xs, --spacing, --letter-spacing, --shadow-offset-y,
--shadow-offset-x, --shadow-spread, --shadow-blur, --shadow-opacity,
--color-shadow-color) that reference themselves via var(--name); remove these
circular declarations from the `@theme` inline block (or replace them with
concrete values if the theme must override them) so the variables fall back to
the intended definitions in :root and no longer resolve to empty/initial values.
| const handleSearch = useCallback(async () => { | ||
| if (!town) return; | ||
|
|
||
| // Reset state | ||
| setPhase("generating_urls"); | ||
| setStatusMessage("Asking Gemini to generate property listing URLs..."); | ||
| setAgents([]); | ||
| setRawListings([]); | ||
| setDeals([]); | ||
| setError(""); | ||
|
|
||
| try { | ||
| const response = await fetch("/api/search", { | ||
| method: "POST", | ||
| headers: { "Content-Type": "application/json" }, | ||
| body: JSON.stringify({ | ||
| town, | ||
| flatType, | ||
| discountThreshold: parseInt(discountThreshold), | ||
| }), | ||
| }); | ||
|
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Missing request cancellation on component unmount or new search.
If the user navigates away or initiates a new search while one is in progress, the previous fetch stream will continue processing and updating state. This can cause memory leaks and unexpected state updates.
Suggested fix using AbortController
+ const abortControllerRef = useRef<AbortController | null>(null);
const handleSearch = useCallback(async () => {
if (!town) return;
+ // Cancel any in-progress search
+ abortControllerRef.current?.abort();
+ abortControllerRef.current = new AbortController();
// Reset state
setPhase("generating_urls");
// ...
try {
const response = await fetch("/api/search", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ town, flatType, discountThreshold: parseInt(discountThreshold) }),
+ signal: abortControllerRef.current.signal,
});
// ...
} catch (err) {
+ if (err instanceof Error && err.name === 'AbortError') return;
setPhase("error");
setError(err instanceof Error ? err.message : "Unknown error");
}
}, [town, flatType, discountThreshold, extractListings]);
+ // Cleanup on unmount
+ useEffect(() => {
+ return () => abortControllerRef.current?.abort();
+ }, []);🤖 Prompt for AI Agents
In `@home-snipe/app/page.tsx` around lines 154 - 175, The fetch in handleSearch
should be cancellable to prevent updates after unmount or when a new search
starts: create an AbortController for each handleSearch invocation (store the
current controller in a ref), pass controller.signal to the fetch call in
handleSearch, and call controller.abort() before starting a new search; also add
a useEffect cleanup that aborts the active controller on component unmount. In
the fetch error handling, detect an aborted request (error.name ===
"AbortError") and skip calling setters like setPhase, setStatusMessage,
setAgents, setRawListings, setDeals, and setError to avoid state updates after
abort. Ensure the unique symbols referenced are handleSearch, the fetch
invocation inside it, and the state setters used after the fetch.
| <p className="text-[10px] text-muted-foreground truncate"> | ||
| {new URL(agent.url).hostname.replace('www.', '')} | ||
| </p> |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Uncaught exception risk when parsing URL.
new URL(agent.url) will throw if agent.url is malformed or empty. This would crash the component during render.
Suggested fix
<p className="text-[10px] text-muted-foreground truncate">
- {new URL(agent.url).hostname.replace('www.', '')}
+ {(() => {
+ try {
+ return new URL(agent.url).hostname.replace('www.', '');
+ } catch {
+ return agent.url;
+ }
+ })()}
</p>Or extract to a helper function for cleaner code:
const getHostname = (url: string): string => {
try {
return new URL(url).hostname.replace('www.', '');
} catch {
return url;
}
};📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| <p className="text-[10px] text-muted-foreground truncate"> | |
| {new URL(agent.url).hostname.replace('www.', '')} | |
| </p> | |
| <p className="text-[10px] text-muted-foreground truncate"> | |
| {(() => { | |
| try { | |
| return new URL(agent.url).hostname.replace('www.', ''); | |
| } catch { | |
| return agent.url; | |
| } | |
| })()} | |
| </p> |
🤖 Prompt for AI Agents
In `@home-snipe/app/page.tsx` around lines 665 - 667, The JSX uses new
URL(agent.url) which can throw on malformed or empty strings; wrap parsing in a
safe helper (e.g., getHostname(url: string)) that tries new
URL(url).hostname.replace('www.', '') inside a try/catch and returns a fallback
(like the original url or empty string) on error, then replace the inline new
URL(agent.url).hostname... expression with a call to getHostname(agent.url) in
the component render.
| try { | ||
| const response = await fetch(MINO_API_URL, { | ||
| method: "POST", | ||
| headers: { | ||
| "X-API-Key": key, | ||
| "Content-Type": "application/json", | ||
| }, | ||
| body: JSON.stringify(config), | ||
| }); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Add timeout to prevent indefinite hangs.
Similar to the API route, this fetch call lacks a timeout. Consider adding an AbortController with a reasonable timeout (e.g., 60-120 seconds for automation tasks).
🔧 Proposed fix
+ const controller = new AbortController();
+ const timeoutId = setTimeout(() => controller.abort(), 120000); // 2 min timeout
+
try {
const response = await fetch(MINO_API_URL, {
method: "POST",
headers: {
"X-API-Key": key,
"Content-Type": "application/json",
},
body: JSON.stringify(config),
+ signal: controller.signal,
});
+
+ clearTimeout(timeoutId);Also clear the timeout in the catch block:
} catch (error) {
+ clearTimeout(timeoutId);
const errorMsg = error instanceof Error ? error.message : String(error);🤖 Prompt for AI Agents
In `@home-snipe/lib/mino-client.ts` around lines 48 - 56, The fetch to
MINO_API_URL in mino-client.ts can hang indefinitely; wrap it with an
AbortController and a timer (e.g., 60–120s) and pass controller.signal into
fetch (the POST block where headers/body are set) so the request is aborted on
timeout; store the timeout id, clearTimeout on successful response before
returning, and also clearTimeout and call controller.abort() in the
catch/finally path to ensure the timer is cleaned up and the request is
cancelled on errors/timeouts.
| if (options?.proxy) { | ||
| config.proxy_config = { | ||
| enabled: true, | ||
| country_code: options.proxy as "US" | "GB" | "CA" | "DE" | "FR" | "JP" | "AU", | ||
| }; | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Unsafe type assertion for country_code.
The options.proxy string is cast directly to the country code union type without validation. Invalid values will be silently accepted and sent to the API.
🔧 Proposed fix with validation
+const VALID_COUNTRY_CODES = ["US", "GB", "CA", "DE", "FR", "JP", "AU"] as const;
+type CountryCode = typeof VALID_COUNTRY_CODES[number];
+
+function isValidCountryCode(code: string): code is CountryCode {
+ return VALID_COUNTRY_CODES.includes(code as CountryCode);
+}
if (options?.proxy) {
+ if (!isValidCountryCode(options.proxy)) {
+ throw new Error(`Invalid proxy country code: ${options.proxy}. Valid codes: ${VALID_COUNTRY_CODES.join(", ")}`);
+ }
config.proxy_config = {
enabled: true,
- country_code: options.proxy as "US" | "GB" | "CA" | "DE" | "FR" | "JP" | "AU",
+ country_code: options.proxy,
};
}🤖 Prompt for AI Agents
In `@home-snipe/lib/mino-client.ts` around lines 166 - 171, The code unsafely
asserts options.proxy into the country_code union when building
config.proxy_config; instead validate options.proxy against the allowed country
codes before assigning it. In the block that sets config.proxy_config
(referencing config.proxy_config and options.proxy in mino-client.ts), check
that options.proxy is one of ["US","GB","CA","DE","FR","JP","AU"] (or derive
this set from the union/type guard), and only set country_code if it matches;
otherwise omit proxy_config or throw/log a clear error so invalid strings are
not sent to the API.
| @@ -0,0 +1,169 @@ | |||
| # TinyFish - HDB Deal Sniper | |||
|
|
|||
| **Live Demo:** https://home-snipe.vercel.app | |||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Use a markdown link instead of a bare URL.
This avoids MD034 and keeps formatting consistent.
✏️ Suggested change
-**Live Demo:** https://home-snipe.vercel.app
+**Live Demo:** [home-snipe.vercel.app](https://home-snipe.vercel.app)📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| **Live Demo:** https://home-snipe.vercel.app | |
| **Live Demo:** [home-snipe.vercel.app](https://home-snipe.vercel.app) |
🧰 Tools
🪛 markdownlint-cli2 (0.18.1)
3-3: Bare URL used
(MD034, no-bare-urls)
🤖 Prompt for AI Agents
In `@home-snipe/README.md` at line 3, Replace the bare URL after the "**Live
Demo:**" text with a Markdown link so the README uses proper link syntax and
avoids MD034; specifically change the string "**Live Demo:**
https://home-snipe.vercel.app" to use a link like "**Live Demo:** [Live
Demo](https://home-snipe.vercel.app)" (or similar descriptive link text) to keep
formatting consistent.
home-snipe
Live Demo: https://home-snipe.vercel.app
Overview
Real-time Singapore HDB resale deal finder that uses the Source → Extract → Present pipeline pattern. Gemini generates property listing URLs, Mino browser agents scrape them in parallel, and Gemini analyzes results to identify underpriced deals.
Mino API Integration
This use case demonstrates Mino API usage for browser automation.
Tech Stack
Contributor: Pranav Janakiraman (@pranavjana)