Auto-deploy: 2026-08-05 17:41:16

This commit is contained in:
Hamza-Ayed
2026-08-05 17:41:16 +03:00
parent ccdbd29770
commit 380f3e4811
3 changed files with 66 additions and 19 deletions
+37 -15
View File
@@ -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
View File
@@ -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
View File
File diff suppressed because one or more lines are too long