google dork links (#239)

* Add ready tab Google dork links

* ui changes

* docs

* ci fix
This commit is contained in:
Shaheer Sarfaraz 2026-02-28 22:23:50 +00:00 committed by GitHub
parent 432529b581
commit 6a19fff436
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 361 additions and 46 deletions

View File

@ -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.

View File

@ -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

View File

@ -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(
<MemoryRouter>
<ReadyPanel
job={createJob({
employer: "HP",
title: "Frontend Engineer",
skills: "Wolf Security, React, TypeScript",
})}
onJobUpdated={vi.fn()}
onJobMoved={vi.fn()}
/>
</MemoryRouter>,
);
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"',
);
});
});

View File

@ -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<ReadyPanelProps> = ({
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<ReadyPanelProps> = ({
</div>
</div>
{/*
APPLICATION KIT SUMMARY
Abstract representation of what the PDF contains - verify at a glance
*/}
<div className="flex-1 py-4 space-y-4">
{/* Job identity - confirm this is the right role */}
<div className="space-y-3">
<FitAssessment job={job} />
<TailoredSummary job={job} />
{googleDorks.length > 0 ? (
<ReadySummaryAccordion
icon={ExternalLink}
summary={
<>
{googleDorks.length}{" "}
{googleDorks.length === 1 ? "search link" : "search links"}
</>
}
value="search-dorks"
>
<div className="text-muted-foreground flex flex-col items-start gap-2">
{googleDorks.map((dork) => (
<a
key={dork.query}
href={dork.href}
rel="noopener noreferrer"
target="_blank"
title={dork.query}
className={cn(
buttonVariants({ variant: "link", size: "sm" }),
"justify-start w-fit h-fit gap-1 px-0 wrap-break-word",
)}
>
{dork.label}
<ExternalLink className="ml-1" />
</a>
))}
</div>
</ReadySummaryAccordion>
) : null}
{/* Project selection - expandable accordion */}
<Accordion type="single" collapsible className="w-full">
<AccordionItem value="projects" className="border-none">
<AccordionTrigger className="hover:no-underline py-0 data-[state=open]:pb-2">
<div className="flex items-center gap-3 w-full">
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-md bg-muted/50 text-muted-foreground">
<FolderKanban className="h-4 w-4" />
</div>
<div className="min-w-0 flex-1 text-left">
<div className="text-sm font-medium text-foreground leading-tight">
{selectedProjectIds.length}{" "}
{selectedProjectIds.length === 1 ? "project" : "projects"}{" "}
selected
</div>
</div>
</div>
</AccordionTrigger>
<AccordionContent className="pt-1 pl-11">
<ul className="list-disc text-xs text-muted-foreground space-y-1">
{selectedProjectIds.map((id) => {
const name = catalog.find((p) => p.id === id)?.name;
if (!name) return null;
return <li key={id}>{name}</li>;
})}
{selectedProjectIds.length === 0 && (
<li className="list-none italic">No projects selected</li>
)}
</ul>
</AccordionContent>
</AccordionItem>
</Accordion>
<ReadySummaryAccordion
icon={FolderKanban}
summary={
<>
{selectedProjectIds.length}{" "}
{selectedProjectIds.length === 1 ? "project" : "projects"}{" "}
selected
</>
}
value="projects"
>
<ul className="list-disc text-xs text-muted-foreground space-y-1">
{selectedProjectIds.map((id) => {
const name = catalog.find((p) => p.id === id)?.name;
if (!name) return null;
return <li key={id}>{name}</li>;
})}
{selectedProjectIds.length === 0 && (
<li className="list-none italic">No projects selected</li>
)}
</ul>
</ReadySummaryAccordion>
</div>
</div>

View File

@ -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<ReadySummaryAccordionProps> = ({
children,
icon: Icon,
summary,
value,
}) => {
return (
<Accordion type="single" collapsible className="w-full">
<AccordionItem value={value} className="border-none">
<AccordionTrigger className="cursor-pointer rounded-xl border border-border/40 px-2 py-1 hover:bg-muted/50 hover:no-underline data-[state=open]:rounded-b-none data-[state=open]:bg-muted/10 data-[state=open]:pb-2">
<div className="flex items-center gap-3 w-full">
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-md bg-muted/50 text-muted-foreground">
<Icon className="h-4 w-4" />
</div>
<div className="min-w-0 flex-1 text-left">
<div className="text-sm font-medium text-foreground leading-tight">
{summary}
</div>
</div>
</div>
</AccordionTrigger>
<AccordionContent className="rounded-b-xl border border-border/40 bg-muted/10 pt-4 pl-13">
{children}
</AccordionContent>
</AccordionItem>
</Accordion>
);
};

View File

@ -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([]);
});
});

View File

@ -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<string>();
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;
}

View File

@ -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",