Auto-deploy: 2026-08-05 17:41:16
This commit is contained in:
+37
-15
@@ -66,11 +66,12 @@
|
|||||||
'.topcard__title',
|
'.topcard__title',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// LinkedIn renders screen-reader-only headings throughout its chrome
|
// LinkedIn's job panel is full of text that is neither navigation nor the
|
||||||
// ("0 notifications", "1 new message"). They are real <h2> elements with
|
// job title: screen-reader-only headings ("0 notifications"), and product
|
||||||
// real text, so any document-wide heading scan will happily return one as
|
// furniture inside the detail column itself ("Are these results helpful?",
|
||||||
// the job title. Require the element to be visibly rendered and to not look
|
// "Your profile and resume match...", "Get personalized tips..."). A
|
||||||
// like navigation furniture.
|
// heuristic DOM scan cannot reliably tell those from a job title, so the
|
||||||
|
// scan is now the LAST resort rather than the second.
|
||||||
function isVisible(el) {
|
function isVisible(el) {
|
||||||
if (!el || !el.offsetParent) return false;
|
if (!el || !el.offsetParent) return false;
|
||||||
if (el.closest('[aria-hidden="true"]')) return false;
|
if (el.closest('[aria-hidden="true"]')) return false;
|
||||||
@@ -80,20 +81,48 @@
|
|||||||
return !/visually-hidden|sr-only|a11y-text/.test(cls);
|
return !/visually-hidden|sr-only|a11y-text/.test(cls);
|
||||||
}
|
}
|
||||||
|
|
||||||
const NAV_NOISE = /notification|message|invitation|my network|feed update|search|premium|^\d+\s|jobs|people|similar|featured|recommended/i;
|
const NAV_NOISE = new RegExp([
|
||||||
|
// chrome / navigation
|
||||||
|
'notification', 'message', 'invitation', 'my network', 'feed update',
|
||||||
|
'premium', 'search', 'similar', 'featured', 'recommended',
|
||||||
|
// in-panel product furniture — the class of string that broke this before
|
||||||
|
'helpful', 'results', 'feedback', 'your profile', 'match', 'tips',
|
||||||
|
'people you', 'about the job', 'sign in', 'join now', 'people also',
|
||||||
|
'jobs based on', 'reach out',
|
||||||
|
// counters
|
||||||
|
'^\\d+\\s'
|
||||||
|
].join('|'), 'i');
|
||||||
|
|
||||||
function plausibleTitle(text) {
|
function plausibleTitle(text) {
|
||||||
return text.length > 4 && text.length < 100 && !NAV_NOISE.test(text);
|
return text.length > 4 && text.length < 100 && !NAV_NOISE.test(text);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback: a heading inside the job detail panel only — never page-wide.
|
// Preferred fallback: the browser tab title. LinkedIn sets it to
|
||||||
|
// "(N) Job Title | Company | LinkedIn" on every job page, it is not part of
|
||||||
|
// the React DOM that keeps getting restructured, and it cannot pick up a
|
||||||
|
// stray card heading. This is far more reliable than guessing at headings,
|
||||||
|
// so it runs before the DOM scan, not after it.
|
||||||
|
if (!data.jobTitle) {
|
||||||
|
// The "(N)" prefix is LinkedIn's unread counter and can read "(99+)".
|
||||||
|
const m = document.title.match(/^(?:\(\d+\+?\)\s*)?(.+?)\s*[|·]/);
|
||||||
|
if (m) {
|
||||||
|
const candidate = m[1].trim();
|
||||||
|
if (plausibleTitle(candidate) && !/^linkedin$/i.test(candidate)) {
|
||||||
|
data.jobTitle = candidate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Last resort: a heading inside the job detail panel only — never page-wide.
|
||||||
if (!data.jobTitle) {
|
if (!data.jobTitle) {
|
||||||
const jobPanel = document.querySelector(
|
const jobPanel = document.querySelector(
|
||||||
'.scaffold-layout__detail-column, .jobs-search__job-details, .job-view-layout, ' +
|
'.scaffold-layout__detail-column, .jobs-search__job-details, .job-view-layout, ' +
|
||||||
'.jobs-details, [class*="jobs-unified-top-card"], main'
|
'.jobs-details, [class*="jobs-unified-top-card"], main'
|
||||||
);
|
);
|
||||||
if (jobPanel) {
|
if (jobPanel) {
|
||||||
for (const h of jobPanel.querySelectorAll('h1, h2')) {
|
// h1 first: the job title is a level-1 heading, while the cards that
|
||||||
|
// polluted this before ("Are these results helpful?") are h2/h3.
|
||||||
|
for (const h of [...jobPanel.querySelectorAll('h1'), ...jobPanel.querySelectorAll('h2')]) {
|
||||||
const text = h.textContent.trim();
|
const text = h.textContent.trim();
|
||||||
if (isVisible(h) && plausibleTitle(text)) {
|
if (isVisible(h) && plausibleTitle(text)) {
|
||||||
data.jobTitle = text;
|
data.jobTitle = text;
|
||||||
@@ -103,13 +132,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Last resort: the browser tab title, which LinkedIn formats as
|
|
||||||
// "(N) Job Title | Company | LinkedIn". Still better than a nav label.
|
|
||||||
if (!data.jobTitle) {
|
|
||||||
const m = document.title.match(/^(?:\(\d+\)\s*)?(.+?)\s*\|/);
|
|
||||||
if (m && plausibleTitle(m[1].trim())) data.jobTitle = m[1].trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Company Name
|
// ── Company Name
|
||||||
data.company = trySelectors([
|
data.company = trySelectors([
|
||||||
'.job-details-jobs-unified-top-card__company-name a',
|
'.job-details-jobs-unified-top-card__company-name a',
|
||||||
|
|||||||
+28
-3
@@ -520,9 +520,34 @@ PROMPT;
|
|||||||
if ($pageCount <= $TARGET_PAGES) break;
|
if ($pageCount <= $TARGET_PAGES) break;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ATS parsers key on the filename too — "Name - Role.pdf" reads cleanly.
|
// ATS parsers key on the filename too — "Name - Role.pdf" reads cleanly,
|
||||||
$safeName = preg_replace('/[^a-zA-Z0-9\-_ ]/', '', $id['fullName']);
|
// and a recruiter sees it before opening anything.
|
||||||
$safeTitle = preg_replace('/[^a-zA-Z0-9\-_ ]/', '', $jobTitle ?: 'CV');
|
//
|
||||||
|
// Scraping a job title out of LinkedIn's DOM is fragile, and a bad
|
||||||
|
// scrape has shipped filenames like "Hamza Ayed - 0 notifications.pdf"
|
||||||
|
// and "Hamza Ayed - Are these results helpful.pdf". The client is fixed,
|
||||||
|
// but the server must not depend on the client being right: reject
|
||||||
|
// anything that does not look like a job title and fall back to the
|
||||||
|
// profile's own positioning instead.
|
||||||
|
$rawTitle = trim($jobTitle);
|
||||||
|
$looksLikeUiText = preg_match(
|
||||||
|
'/helpful|result|feedback|notification|message|your profile|match|tips|about the job|^\d/i',
|
||||||
|
$rawTitle
|
||||||
|
);
|
||||||
|
// Real job titles carry a role noun. Requiring one rejects stray UI
|
||||||
|
// strings without hardcoding a list of every possible title.
|
||||||
|
$hasRoleNoun = preg_match(
|
||||||
|
'/engineer|developer|architect|lead|manager|consultant|specialist|analyst|designer|' .
|
||||||
|
'programmer|scientist|director|head of|principal|staff|senior|founding|cto|technologist/i',
|
||||||
|
$rawTitle
|
||||||
|
);
|
||||||
|
|
||||||
|
if ($rawTitle === '' || $looksLikeUiText || !$hasRoleNoun) {
|
||||||
|
$rawTitle = $id['primaryTitle'];
|
||||||
|
}
|
||||||
|
|
||||||
|
$safeName = preg_replace('/[^\p{L}0-9\-_ ]/u', '', $id['fullName']);
|
||||||
|
$safeTitle = preg_replace('/[^\p{L}0-9\-_ ()\/]/u', '', $rawTitle);
|
||||||
$safeTitle = trim(preg_replace('/\s+/', ' ', $safeTitle));
|
$safeTitle = trim(preg_replace('/\s+/', ' ', $safeTitle));
|
||||||
$fileName = trim("{$safeName} - {$safeTitle}") . '.pdf';
|
$fileName = trim("{$safeName} - {$safeTitle}") . '.pdf';
|
||||||
|
|
||||||
|
|||||||
+1
-1
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user