google dork links (#239)
* Add ready tab Google dork links * ui changes * docs * ci fix
This commit is contained in:
parent
432529b581
commit
6a19fff436
@ -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.
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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"',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@ -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>
|
||||
|
||||
|
||||
46
orchestrator/src/client/components/ReadySummaryAccordion.tsx
Normal file
46
orchestrator/src/client/components/ReadySummaryAccordion.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@ -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([]);
|
||||
});
|
||||
});
|
||||
122
orchestrator/src/client/components/ready-panel-google-dorks.ts
Normal file
122
orchestrator/src/client/components/ready-panel-google-dorks.ts
Normal 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;
|
||||
}
|
||||
@ -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",
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user