From 6a19fff43638406e27d25a6dfc0fedfecb61b4ff Mon Sep 17 00:00:00 2001 From: Shaheer Sarfaraz <53654735+DaKheera47@users.noreply.github.com> Date: Sat, 28 Feb 2026 22:23:50 +0000 Subject: [PATCH] google dork links (#239) * Add ready tab Google dork links * ui changes * docs * ci fix --- docs-site/docs/features/orchestrator.md | 13 ++ .../workflows/find-jobs-and-apply-workflow.md | 7 +- .../src/client/components/ReadyPanel.test.tsx | 40 ++++++ .../src/client/components/ReadyPanel.tsx | 103 +++++++++------ .../components/ReadySummaryAccordion.tsx | 46 +++++++ .../ready-panel-google-dorks.test.ts | 74 +++++++++++ .../components/ready-panel-google-dorks.ts | 122 ++++++++++++++++++ orchestrator/src/components/ui/button.tsx | 2 +- 8 files changed, 361 insertions(+), 46 deletions(-) create mode 100644 orchestrator/src/client/components/ReadySummaryAccordion.tsx create mode 100644 orchestrator/src/client/components/ready-panel-google-dorks.test.ts create mode 100644 orchestrator/src/client/components/ready-panel-google-dorks.ts diff --git a/docs-site/docs/features/orchestrator.md b/docs-site/docs/features/orchestrator.md index 00b0a14..0580006 100644 --- a/docs-site/docs/features/orchestrator.md +++ b/docs-site/docs/features/orchestrator.md @@ -36,6 +36,7 @@ It exists to ensure: - a consistent path from discovery to tailored output - clear status transitions across manual and automated workflows - predictable regeneration behavior when job data changes +- faster external research from the Ready tab with prebuilt search links for LinkedIn, GitHub, and broader web results ## How to use it @@ -57,6 +58,18 @@ Ghostwriter is available in `discovered` and `ready` job views. For details, see [Ghostwriter](/docs/next/features/ghostwriter). +### Ready tab search links + +In the `ready` view, JobOps can show prebuilt search links based on the current job's employer, title, and skills. + +This enables you to: + +- quickly open Google searches for likely LinkedIn profiles tied to the company and target skills +- search GitHub for matching public profiles or repositories without rewriting the query yourself +- run a broader web search to gather context before applying + +Open the **search links** row in the Ready summary to reveal the generated links. + ### Opening documentation from the sidebar 1. Open the sidebar menu. diff --git a/docs-site/docs/workflows/find-jobs-and-apply-workflow.md b/docs-site/docs/workflows/find-jobs-and-apply-workflow.md index 2456bd6..858d728 100644 --- a/docs-site/docs/workflows/find-jobs-and-apply-workflow.md +++ b/docs-site/docs/workflows/find-jobs-and-apply-workflow.md @@ -57,9 +57,10 @@ These jobs already have tailored PDFs generated for the specific job description At this stage: 1. Open job details. -2. Optionally enable tracer links for that specific job. -3. Download the tailored PDF. -4. Submit your application externally. +2. Open the **search links** row when you want quick external research on LinkedIn, GitHub, or the wider web. +3. Optionally enable tracer links for that specific job. +4. Download the tailored PDF. +5. Submit your application externally. ### 5) Mark jobs as applied in JobOps diff --git a/orchestrator/src/client/components/ReadyPanel.test.tsx b/orchestrator/src/client/components/ReadyPanel.test.tsx index 74b2576..cc31d91 100644 --- a/orchestrator/src/client/components/ReadyPanel.test.tsx +++ b/orchestrator/src/client/components/ReadyPanel.test.tsx @@ -147,4 +147,44 @@ describe("ReadyPanel", () => { screen.queryByTestId("job-details-edit-drawer"), ).not.toBeInTheDocument(); }); + + it("renders descriptive google dork links in the ready summary", async () => { + render( + + + , + ); + + await waitFor(() => + expect(api.getResumeProjectsCatalog).toHaveBeenCalled(), + ); + + fireEvent.click( + screen.getByRole("button", { + name: /3 search links/i, + }), + ); + + const linkedInLink = screen.getByRole("link", { + name: "LinkedIn profiles with HP, Wolf Security, and React in them", + }); + expect(linkedInLink).toHaveAttribute( + "href", + `https://www.google.com/search?q=${encodeURIComponent('site:linkedin.com/in "HP" "Wolf Security" "React"')}`, + ); + expect(linkedInLink).toHaveAttribute("target", "_blank"); + expect(linkedInLink).toHaveAttribute("rel", "noopener noreferrer"); + expect(linkedInLink).toHaveAttribute( + "title", + 'site:linkedin.com/in "HP" "Wolf Security" "React"', + ); + }); }); diff --git a/orchestrator/src/client/components/ReadyPanel.tsx b/orchestrator/src/client/components/ReadyPanel.tsx index 515576e..74928c8 100644 --- a/orchestrator/src/client/components/ReadyPanel.tsx +++ b/orchestrator/src/client/components/ReadyPanel.tsx @@ -25,13 +25,7 @@ import { import type React from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { toast } from "sonner"; -import { - Accordion, - AccordionContent, - AccordionItem, - AccordionTrigger, -} from "@/components/ui/accordion"; -import { Button } from "@/components/ui/button"; +import { Button, buttonVariants } from "@/components/ui/button"; import { DropdownMenu, DropdownMenuContent, @@ -58,6 +52,8 @@ import { TailorMode } from "./discovered-panel/TailorMode"; import { GhostwriterDrawer } from "./ghostwriter/GhostwriterDrawer"; import { JobDetailsEditDrawer } from "./JobDetailsEditDrawer"; import { KbdHint } from "./KbdHint"; +import { ReadySummaryAccordion } from "./ReadySummaryAccordion"; +import { buildReadyPanelGoogleDorks } from "./ready-panel-google-dorks"; type PanelMode = "ready" | "tailor"; @@ -127,6 +123,10 @@ export const ReadyPanel: React.FC = ({ const selectedProjectIds = useMemo(() => { return job?.selectedProjectIds?.split(",").filter(Boolean) ?? []; }, [job?.selectedProjectIds]); + const googleDorks = useMemo( + () => (job ? buildReadyPanelGoogleDorks(job) : []), + [job], + ); const handleUndoApplied = useCallback( async (jobId: string) => { @@ -427,47 +427,66 @@ export const ReadyPanel: React.FC = ({ - {/* ───────────────────────────────────────────────────────────────────── - APPLICATION KIT SUMMARY - Abstract representation of what the PDF contains - verify at a glance - ───────────────────────────────────────────────────────────────────── */}
- {/* Job identity - confirm this is the right role */}
+ {googleDorks.length > 0 ? ( + + {googleDorks.length}{" "} + {googleDorks.length === 1 ? "search link" : "search links"} + + } + value="search-dorks" + > +
+ {googleDorks.map((dork) => ( + + {dork.label} + + + ))} +
+
+ ) : null} + {/* Project selection - expandable accordion */} - - - -
-
- -
-
-
- {selectedProjectIds.length}{" "} - {selectedProjectIds.length === 1 ? "project" : "projects"}{" "} - selected -
-
-
-
- -
    - {selectedProjectIds.map((id) => { - const name = catalog.find((p) => p.id === id)?.name; - if (!name) return null; - return
  • {name}
  • ; - })} - {selectedProjectIds.length === 0 && ( -
  • No projects selected
  • - )} -
-
-
-
+ + {selectedProjectIds.length}{" "} + {selectedProjectIds.length === 1 ? "project" : "projects"}{" "} + selected + + } + value="projects" + > +
    + {selectedProjectIds.map((id) => { + const name = catalog.find((p) => p.id === id)?.name; + if (!name) return null; + return
  • {name}
  • ; + })} + {selectedProjectIds.length === 0 && ( +
  • No projects selected
  • + )} +
+
diff --git a/orchestrator/src/client/components/ReadySummaryAccordion.tsx b/orchestrator/src/client/components/ReadySummaryAccordion.tsx new file mode 100644 index 0000000..812ac92 --- /dev/null +++ b/orchestrator/src/client/components/ReadySummaryAccordion.tsx @@ -0,0 +1,46 @@ +import type { LucideIcon } from "lucide-react"; +import type React from "react"; +import { + Accordion, + AccordionContent, + AccordionItem, + AccordionTrigger, +} from "@/components/ui/accordion"; + +interface ReadySummaryAccordionProps { + children: React.ReactNode; + icon: LucideIcon; + summary: React.ReactNode; + value: string; +} + +export const ReadySummaryAccordion: React.FC = ({ + children, + icon: Icon, + summary, + value, +}) => { + return ( + + + +
+
+ +
+ +
+
+ {summary} +
+
+
+
+ + + {children} + +
+
+ ); +}; diff --git a/orchestrator/src/client/components/ready-panel-google-dorks.test.ts b/orchestrator/src/client/components/ready-panel-google-dorks.test.ts new file mode 100644 index 0000000..83a7090 --- /dev/null +++ b/orchestrator/src/client/components/ready-panel-google-dorks.test.ts @@ -0,0 +1,74 @@ +import { createJob } from "@shared/testing/factories.js"; +import { describe, expect, it } from "vitest"; +import { buildReadyPanelGoogleDorks } from "./ready-panel-google-dorks"; + +describe("buildReadyPanelGoogleDorks", () => { + it("returns three links from employer, title, and skills", () => { + const links = buildReadyPanelGoogleDorks( + createJob({ + employer: "HP", + title: "Frontend Engineer", + skills: "Wolf Security, React, TypeScript", + }), + ); + + expect(links).toHaveLength(3); + expect(links[0]).toMatchObject({ + query: 'site:linkedin.com/in "HP" "Wolf Security" "React"', + label: "LinkedIn profiles with HP, Wolf Security, and React in them", + }); + expect(links[1]).toMatchObject({ + query: 'site:github.com "HP" "Wolf Security" "React"', + label: "GitHub pages with HP, Wolf Security, and React in them", + }); + expect(links[2]).toMatchObject({ + query: '"HP" "Frontend Engineer" "Wolf Security"', + label: + "Web results with HP, Frontend Engineer, and Wolf Security in them", + }); + expect(links[0]?.href).toContain( + encodeURIComponent('site:linkedin.com/in "HP" "Wolf Security" "React"'), + ); + }); + + it("falls back to tailored skills when raw skills are absent", () => { + const links = buildReadyPanelGoogleDorks( + createJob({ + employer: "Acme", + title: "Backend Engineer", + skills: null, + tailoredSkills: JSON.stringify(["Node.js", "TypeScript"]), + }), + ); + + expect(links[0]?.query).toBe( + 'site:linkedin.com/in "Acme" "Node.js" "TypeScript"', + ); + }); + + it("deduplicates repeated keywords and excludes employer matches", () => { + const links = buildReadyPanelGoogleDorks( + createJob({ + employer: "Acme", + skills: "Acme, React, react, TypeScript, TypeScript", + }), + ); + + expect(links[0]?.query).toBe( + 'site:linkedin.com/in "Acme" "React" "TypeScript"', + ); + expect(links[1]?.query).toBe('site:github.com "Acme" "React" "TypeScript"'); + }); + + it("returns no links when employer and usable keywords are missing", () => { + const links = buildReadyPanelGoogleDorks( + createJob({ + employer: "", + skills: null, + tailoredSkills: null, + }), + ); + + expect(links).toEqual([]); + }); +}); diff --git a/orchestrator/src/client/components/ready-panel-google-dorks.ts b/orchestrator/src/client/components/ready-panel-google-dorks.ts new file mode 100644 index 0000000..8f3afca --- /dev/null +++ b/orchestrator/src/client/components/ready-panel-google-dorks.ts @@ -0,0 +1,122 @@ +import type { Job } from "@shared/types.js"; + +export interface ReadyPanelGoogleDorkLink { + href: string; + label: string; + query: string; +} + +function splitRawSkills(skills: string): string[] { + return skills + .split(/[,\n|]+/g) + .map((value) => value.trim()) + .filter(Boolean); +} + +function parseTailoredSkills(raw: string): string[] { + try { + const parsed = JSON.parse(raw) as unknown; + if (!Array.isArray(parsed)) return []; + + return parsed + .filter((value): value is string => typeof value === "string") + .map((value) => value.trim()) + .filter(Boolean); + } catch { + return []; + } +} + +function getKeywordTerms(job: Job): string[] { + const employer = job.employer.trim().toLowerCase(); + const rawTerms = job.skills + ? splitRawSkills(job.skills) + : job.tailoredSkills + ? parseTailoredSkills(job.tailoredSkills) + : []; + + const seen = new Set(); + const terms: string[] = []; + + for (const term of rawTerms) { + const normalized = term.toLowerCase(); + if (normalized === employer || seen.has(normalized)) continue; + seen.add(normalized); + terms.push(term); + if (terms.length === 2) break; + } + + return terms; +} + +function quoteTerms(terms: string[]): string { + return terms.map((term) => `"${term}"`).join(" "); +} + +function formatTermList(terms: string[]): string { + if (terms.length === 0) return ""; + if (terms.length === 1) return terms[0]; + if (terms.length === 2) return `${terms[0]} and ${terms[1]}`; + return `${terms.slice(0, -1).join(", ")}, and ${terms.at(-1)}`; +} + +function buildDork( + prefix: "LinkedIn profiles" | "GitHub pages" | "Web results", + queryTerms: string[], +): ReadyPanelGoogleDorkLink | null { + if (queryTerms.length === 0) return null; + + const query = quoteTerms(queryTerms); + return { + query, + href: `https://www.google.com/search?q=${encodeURIComponent(query)}`, + label: `${prefix} with ${formatTermList(queryTerms)} in them`, + }; +} + +export function buildReadyPanelGoogleDorks( + job: Job, +): ReadyPanelGoogleDorkLink[] { + const employer = job.employer.trim(); + const title = job.title.trim(); + const keywords = getKeywordTerms(job); + + if (!employer && keywords.length === 0) { + return []; + } + + const linkedinTerms = [employer, ...keywords].filter(Boolean); + const githubTerms = [employer, ...keywords].filter(Boolean); + const webTerms = [employer, title, keywords[0]].filter(Boolean); + + const links: ReadyPanelGoogleDorkLink[] = []; + + const linkedinQuery = + linkedinTerms.length > 0 + ? `site:linkedin.com/in ${quoteTerms(linkedinTerms)}` + : ""; + if (linkedinQuery) { + links.push({ + query: linkedinQuery, + href: `https://www.google.com/search?q=${encodeURIComponent(linkedinQuery)}`, + label: `LinkedIn profiles with ${formatTermList(linkedinTerms)} in them`, + }); + } + + const githubQuery = + githubTerms.length > 0 ? `site:github.com ${quoteTerms(githubTerms)}` : ""; + if (githubQuery) { + links.push({ + query: githubQuery, + href: `https://www.google.com/search?q=${encodeURIComponent(githubQuery)}`, + label: `GitHub pages with ${formatTermList(githubTerms)} in them`, + }); + } + + const webLink = buildDork("Web results", webTerms); + if (webLink) { + links.push(webLink); + } + + return links; +} diff --git a/orchestrator/src/components/ui/button.tsx b/orchestrator/src/components/ui/button.tsx index 9bdee0c..05fbf88 100644 --- a/orchestrator/src/components/ui/button.tsx +++ b/orchestrator/src/components/ui/button.tsx @@ -17,7 +17,7 @@ const buttonVariants = cva( secondary: "bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80", ghost: "hover:bg-accent hover:text-accent-foreground", - link: "text-primary underline-offset-4 hover:underline", + link: "text-foreground underline-offset-4 hover:underline py-0 h-auto", }, size: { default: "h-9 px-4 py-2",