Skip to content

Commit bed0015

Browse files
fix: update LinkedIn selectors for Feb 2026 DOM changes
LinkedIn switched to CSS-in-JS with hashed class names. Updated selectors to use stable data-view-name, data-testid, and componentkey attributes instead of class names that now have unpredictable hashes. Key changes: - Post detection uses data-view-name="feed-full-update" - Post ID extraction uses componentkey attribute pattern - Author info extraction uses data-view-name="feed-actor-image" - Content extraction uses data-testid="expandable-text-box" - Updated tests to match new DOM structure
1 parent d33083b commit bed0015

File tree

6 files changed

+244
-110
lines changed

6 files changed

+244
-110
lines changed

CHANGELOG.md

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
## [1.1.2] - 2026-02-04
11+
12+
### Fixed
13+
14+
- Updated LinkedIn DOM selectors for February 2026 LinkedIn UI changes (CSS-in-JS with hashed class names)
15+
- Post detection now uses stable `data-view-name` and `data-testid` attributes instead of class names
16+
- Post ID extraction uses `componentkey` attribute instead of deprecated `data-urn`
17+
1018
## [1.1.1] - 2025-01-21
1119

1220
### Added
@@ -60,7 +68,8 @@ Initial release of ReplyQueue, a Chrome extension that helps content creators fi
6068
- Reply suggestion generation with customizable writing style
6169
- Side panel UI with post cards, filtering, and settings
6270

63-
[Unreleased]: https://github.com/charlesjones-dev/replyqueue/compare/v1.1.1...HEAD
71+
[Unreleased]: https://github.com/charlesjones-dev/replyqueue/compare/v1.1.2...HEAD
72+
[1.1.2]: https://github.com/charlesjones-dev/replyqueue/compare/v1.1.1...v1.1.2
6473
[1.1.1]: https://github.com/charlesjones-dev/replyqueue/compare/v1.1.0...v1.1.1
6574
[1.1.0]: https://github.com/charlesjones-dev/replyqueue/compare/v1.0.0...v1.1.0
6675
[1.0.0]: https://github.com/charlesjones-dev/replyqueue/releases/tag/v1.0.0

manifest.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
"manifest_version": 3,
33
"name": "ReplyQueue",
44
"description": "Side panel for finding social media posts relevant to your blog content and get AI-powered reply suggestions.",
5-
"version": "1.1.1",
5+
"version": "1.1.2",
66
"icons": {
77
"16": "icons/icon16.png",
88
"48": "icons/icon48.png",

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "replyqueue",
3-
"version": "1.1.1",
3+
"version": "1.1.2",
44
"type": "module",
55
"homepage": "https://chromewebstore.google.com/detail/replyqueue/lkdecdgjijicaehjngnehdhoahjipnpg",
66
"scripts": {

src/platforms/linkedin/adapter.ts

Lines changed: 119 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,33 @@ export class LinkedInAdapter implements PlatformAdapter {
3535
* Get the unique ID for a post element
3636
*/
3737
getPostId(element: Element): string | null {
38-
// Try to get the data-urn attribute
38+
// Try componentkey attribute first (new LinkedIn DOM structure)
39+
const componentKey = element.getAttribute(linkedInDataAttributes.componentKey);
40+
if (componentKey) {
41+
const expandedMatch = componentKey.match(linkedInDataAttributes.componentKeyPattern);
42+
if (expandedMatch) {
43+
return expandedMatch[1];
44+
}
45+
// Try simple pattern for non-expanded keys
46+
const simpleMatch = componentKey.match(linkedInDataAttributes.componentKeySimplePattern);
47+
if (simpleMatch) {
48+
return simpleMatch[1];
49+
}
50+
}
51+
52+
// Check parent/ancestor elements for componentkey
53+
const parentWithKey = element.closest('[componentkey*="FeedType"]');
54+
if (parentWithKey) {
55+
const parentKey = parentWithKey.getAttribute('componentkey');
56+
if (parentKey) {
57+
const match = parentKey.match(linkedInDataAttributes.componentKeyPattern);
58+
if (match) {
59+
return match[1];
60+
}
61+
}
62+
}
63+
64+
// Legacy: Try to get the data-urn attribute
3965
const urn = element.getAttribute(linkedInDataAttributes.postUrn);
4066
if (urn) {
4167
const activityMatch = urn.match(linkedInDataAttributes.activityUrnPattern);
@@ -48,7 +74,7 @@ export class LinkedInAdapter implements PlatformAdapter {
4874
}
4975
}
5076

51-
// Check parent elements for the URN
77+
// Legacy: Check parent elements for the URN
5278
const parent = element.closest('[data-urn]');
5379
if (parent) {
5480
const parentUrn = parent.getAttribute('data-urn');
@@ -158,6 +184,23 @@ export class LinkedInAdapter implements PlatformAdapter {
158184
* Extract the author's name from a post element
159185
*/
160186
private extractAuthorName(container: Element): string | undefined {
187+
// New structure: look for the actor link and find the name in nearby elements
188+
const actorImage = container.querySelector('[data-view-name="feed-actor-image"]');
189+
if (actorImage) {
190+
// The name is typically in a sibling anchor element's first paragraph
191+
const nextAnchor = actorImage.nextElementSibling;
192+
if (nextAnchor?.tagName === 'A') {
193+
const namePara = nextAnchor.querySelector('p');
194+
if (namePara) {
195+
const text = namePara.textContent?.trim();
196+
if (text && !text.includes('followers')) {
197+
return text.replace(/\s+/g, ' ').split('\n')[0].trim();
198+
}
199+
}
200+
}
201+
}
202+
203+
// Try original selectors as fallback
161204
const selectors = this.selectors.authorName.split(',').map((s) => s.trim());
162205

163206
for (const selector of selectors) {
@@ -178,6 +221,23 @@ export class LinkedInAdapter implements PlatformAdapter {
178221
* Extract the author's headline/description
179222
*/
180223
private extractAuthorHeadline(container: Element): string | undefined {
224+
// New structure: look for the actor link and find headline in nearby elements
225+
const actorImage = container.querySelector('[data-view-name="feed-actor-image"]');
226+
if (actorImage) {
227+
const nextAnchor = actorImage.nextElementSibling;
228+
if (nextAnchor?.tagName === 'A') {
229+
const paragraphs = nextAnchor.querySelectorAll('p');
230+
// Second paragraph is typically the headline/followers
231+
if (paragraphs.length >= 2) {
232+
const text = paragraphs[1].textContent?.trim();
233+
if (text) {
234+
return text.split('\n')[0].trim();
235+
}
236+
}
237+
}
238+
}
239+
240+
// Fallback to original selectors
181241
if (!this.selectors.authorHeadline) return undefined;
182242
const text = this.extractTextContent(container, this.selectors.authorHeadline);
183243
if (text) {
@@ -191,13 +251,23 @@ export class LinkedInAdapter implements PlatformAdapter {
191251
* Extract the author's profile URL
192252
*/
193253
private extractAuthorProfileUrl(container: Element): string | undefined {
254+
// New structure: look for the actor image link
255+
const actorImage = container.querySelector('[data-view-name="feed-actor-image"]') as HTMLAnchorElement | null;
256+
if (actorImage?.href) {
257+
const href = actorImage.href;
258+
if (href.includes('/in/') || href.includes('/company/')) {
259+
return href.split('?')[0]; // Remove query params
260+
}
261+
}
262+
263+
// Fallback to original selectors
194264
if (!this.selectors.authorProfileLink) return undefined;
195265

196266
const selectors = this.selectors.authorProfileLink.split(',').map((s) => s.trim());
197267

198268
for (const selector of selectors) {
199269
const link = container.querySelector(selector) as HTMLAnchorElement | null;
200-
if (link?.href && link.href.includes('/in/')) {
270+
if (link?.href && (link.href.includes('/in/') || link.href.includes('/company/'))) {
201271
return link.href.split('?')[0]; // Remove query params
202272
}
203273
}
@@ -230,15 +300,18 @@ export class LinkedInAdapter implements PlatformAdapter {
230300
// Clone the element to avoid modifying the original
231301
const clone = element.cloneNode(true) as Element;
232302

233-
// Remove hidden elements
234-
clone.querySelectorAll('.visually-hidden, [aria-hidden="true"]').forEach((el) => {
303+
// Remove hidden elements (new structure uses class patterns for visually-hidden)
304+
clone.querySelectorAll('.visually-hidden, [aria-hidden="true"], ._7e570f38').forEach((el) => {
235305
// Keep aria-hidden spans that contain the actual visible text
236-
if (!el.closest('.update-components-text')) {
306+
const parent = el.closest('[data-testid="expandable-text-box"]');
307+
if (!parent) {
237308
el.remove();
238309
}
239310
});
240311

241-
// Remove "see more" buttons
312+
// Remove "see more" buttons (new structure)
313+
clone.querySelectorAll('[data-testid="expandable-text-button"]').forEach((el) => el.remove());
314+
// Legacy "see more" buttons
242315
clone.querySelectorAll('.feed-shared-inline-show-more-text__see-more-less-toggle').forEach((el) => el.remove());
243316

244317
let text = clone.textContent || '';
@@ -251,7 +324,7 @@ export class LinkedInAdapter implements PlatformAdapter {
251324
.trim();
252325

253326
// Remove common LinkedIn UI text
254-
text = text.replace(/\s*(see more|see less|more)\s*/gi, '').trim();
327+
text = text.replace(/\s*(see more|see less|more| more)\s*/gi, '').trim();
255328

256329
return text;
257330
}
@@ -329,15 +402,33 @@ export class LinkedInAdapter implements PlatformAdapter {
329402
* Check if the post is a repost
330403
*/
331404
private isRepost(container: Element): boolean {
332-
if (!this.selectors.repostIndicator) return false;
405+
// New structure: check for repost header
406+
const headerText = container.querySelector('[data-view-name="feed-header-text"]');
407+
if (headerText) {
408+
const text = headerText.textContent?.toLowerCase() || '';
409+
if (text.includes('repost')) {
410+
return true;
411+
}
412+
}
333413

334-
const indicator = container.querySelector(this.selectors.repostIndicator);
335-
if (indicator) {
336-
const text = indicator.textContent?.toLowerCase() || '';
337-
return text.includes('repost');
414+
// Also check for the header actor image (indicates someone shared/reposted)
415+
const headerActor = container.querySelector('[data-view-name="feed-header-actor-image"]');
416+
if (headerActor) {
417+
return true;
338418
}
339419

340-
// Also check for reshare URN
420+
// Legacy: check repost indicator selector
421+
if (this.selectors.repostIndicator) {
422+
const indicator = container.querySelector(this.selectors.repostIndicator);
423+
if (indicator) {
424+
const text = indicator.textContent?.toLowerCase() || '';
425+
if (text.includes('repost')) {
426+
return true;
427+
}
428+
}
429+
}
430+
431+
// Legacy: check for reshare URN
341432
const urn = container.getAttribute('data-urn') || '';
342433
return urn.includes('reshare');
343434
}
@@ -346,10 +437,23 @@ export class LinkedInAdapter implements PlatformAdapter {
346437
* Check if the post is sponsored
347438
*/
348439
private isSponsored(container: Element): boolean {
440+
// New structure: check for sponsored indicator
349441
const indicator = container.querySelector(linkedInExtraSelectors.sponsoredIndicator);
350442
if (indicator) return true;
351443

352-
// Check for "Promoted" or "Sponsored" text
444+
// Check for "Promoted" or "Sponsored" text in the actor area
445+
const actorImage = container.querySelector('[data-view-name="feed-actor-image"]');
446+
if (actorImage) {
447+
const nextAnchor = actorImage.nextElementSibling;
448+
if (nextAnchor) {
449+
const text = nextAnchor.textContent?.toLowerCase() || '';
450+
if (text.includes('promoted') || text.includes('sponsored')) {
451+
return true;
452+
}
453+
}
454+
}
455+
456+
// Legacy: check for "Promoted" or "Sponsored" text
353457
const actorDescription = container.querySelector('.update-components-actor__sub-description');
354458
if (actorDescription) {
355459
const text = actorDescription.textContent?.toLowerCase() || '';

0 commit comments

Comments
 (0)