diff --git a/.gitignore b/.gitignore
index 6de233e..ec0ad0d 100644
--- a/.gitignore
+++ b/.gitignore
@@ -2,3 +2,8 @@ node_modules/
dist/
*.zip
.DS_Store
+
+.env
+
+# local working docs (tmp plan)
+docs/
diff --git a/LICENSE b/LICENSE
index ce08d28..d96c50e 100644
--- a/LICENSE
+++ b/LICENSE
@@ -1,6 +1,7 @@
MIT License
Copyright (c) 2026 stefw
+Copyright (c) 2026 40min (fork: OpenRouter provider, English UI)
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
diff --git a/README.fr.md b/README.fr.md
index 3e0a437..40b0a77 100644
--- a/README.fr.md
+++ b/README.fr.md
@@ -9,8 +9,12 @@ du texte. L'engagement bait, l'auto-promo, le hors-sujet et le sponsorisé sont
repliés en une barre d'une ligne, et chaque décision est expliquée directement sur
le post.
-> Projet indépendant, sans lien avec LinkedIn ni TypeSafe AI. Il te faut ta propre
-> clé API TypeSafe.
+> **Note fork :** ce fork ajoute [OpenRouter](https://openrouter.ai) comme voie
+> d'accès alternative à Jev — même modèle, facturé sur ton compte OpenRouter,
+> sans compte TypeSafe.
+
+> Projet indépendant, sans lien avec LinkedIn ni TypeSafe AI. Il te faut ta
+> propre clé API pour le fournisseur choisi (TypeSafe direct, ou OpenRouter).
## Ce que ça fait
@@ -27,8 +31,9 @@ le post.
trop court pour le déclencheur « charger la suite » de LinkedIn ; lkclean le
relance sans déplacer ta position de lecture.
- **Rapide et pas cher** – un appel API par post, toutes les questions évaluées en
- parallèle, verdicts mis en cache pour la session. Environ 0,042 $ par million de
- tokens en entrée : une session de scroll coûte une fraction de centime.
+ parallèle, verdicts mis en cache pour la session. Environ 0,042 $ par million
+ de tokens en entrée (les tokens de sortie sont gratuits) : une session de
+ scroll coûte une fraction de centime, quel que soit le fournisseur.
@@ -43,9 +48,11 @@ le post.
LinkedIn (texte dans `[data-testid="expandable-text-box"]`), avec repli sur
l'ancien balisage `data-id="urn:li:activity:…"`. Le post est identifié par un
hash de son texte, LinkedIn n'exposant plus d'identifiant stable.
-2. L'auteur et le texte partent au service worker, qui appelle
- `POST https://api.typesafe.ai/v1/systemone` avec un *state*
- `{ user_interests, post }` et plusieurs questions typées en une seule requête :
+2. L'auteur et le texte partent au service worker, qui appelle l'endpoint System
+ One — `POST https://api.typesafe.ai/v1/systemone` (TypeSafe direct) ou
+ `POST https://openrouter.ai/api/v1/systemone` (via OpenRouter) — avec un
+ *state* `{ user_interests, post }` et plusieurs questions typées en une seule
+ requête :
| Question | Type | Sens |
| --- | --- | --- |
@@ -78,10 +85,14 @@ npm run build
Puis charge le dossier `dist/` de la même façon.
-Clique sur l'icône de l'extension pour ouvrir les réglages : colle ta clé TypeSafe
-(depuis [console.typesafe.ai](https://console.typesafe.ai)), liste tes centres
-d'intérêt (un par ligne), éventuellement des sujets à bloquer, puis ajuste les
-seuils. Recharge LinkedIn.
+Clique sur l'icône de l'extension pour ouvrir les réglages : choisis le
+fournisseur d'API (**TypeSafe direct** ou **OpenRouter**), colle la clé
+correspondante (depuis [console.typesafe.ai](https://console.typesafe.ai) —
+`ts-…` — ou [openrouter.ai/settings/keys](https://openrouter.ai/settings/keys) —
+`sk-or-…`), liste tes centres d'intérêt (un par ligne), éventuellement des
+sujets à bloquer, puis ajuste les seuils. Mets l'id du modèle en accord avec le
+fournisseur : `jev-latest` (TypeSafe) ou `typesafe/jev-1.14` (OpenRouter).
+Recharge LinkedIn.
Après chaque rebuild, clique sur ↻ sur la carte de l'extension dans
`chrome://extensions`, puis recharge l'onglet LinkedIn.
@@ -90,6 +101,7 @@ Après chaque rebuild, clique sur ↻ sur la carte de l'extension dans
| Réglage | Défaut | Effet |
| --- | --- | --- |
+| Fournisseur d'API | TypeSafe direct | D'où Jev est appelé ; le champ de clé correspondant est utilisé |
| Centres d'intérêt | vide | Si vide, seul le bruit est filtré (bait, auto-promo, sponsorisé) |
| Sujets bloqués | vide | Toujours masqués quand Jev est sûr à ≥ 60 % |
| Seuil de bruit | 70 % | Masque si P(bait) ou P(auto-promo) le dépasse |
@@ -98,13 +110,17 @@ Après chaque rebuild, clique sur ↻ sur la carte de l'extension dans
| Longueur minimale | 40 caractères | Les posts plus courts ne sont jamais évalués |
| Expliquer les posts gardés | oui | La ligne « Gardé par Jev » |
| Pastille d'activité | oui | Le badge flottant Jev |
-| Modèle | `jev-latest` | Épingle une version (ex. `jev-1.13.0`) une fois tes seuils réglés |
+| Modèle | `jev-latest` | Défaut TypeSafe ; sur OpenRouter, `typesafe/jev-1.14`. Épingle une version une fois tes seuils réglés |
+
+Aussi, l'interface (page de réglages) est en anglais dans ce fork : les libellés
+du tableau ci-dessus décrivent l'effet, pas le libellé exact.
## Confidentialité
-- Le nom de l'auteur et le texte de chaque post affiché dans ton fil sont envoyés à
- l'API TypeSafe pour classification. Rien d'autre ne quitte ton navigateur, et
- lkclean n'a ni serveur ni analytics.
+- Le nom de l'auteur et le texte de chaque post affiché dans ton fil sont envoyés
+ au fournisseur Jev configuré (TypeSafe direct, ou OpenRouter) pour la
+ classification. Rien d'autre ne quitte ton navigateur, et lkclean n'a ni
+ serveur ni analytics.
- La clé API est stockée dans le stockage synchronisé de Chrome
(`chrome.storage.sync`).
- Les verdicts sont mis en cache dans `chrome.storage.session` et effacés à la
@@ -122,7 +138,8 @@ npm run typecheck # tsc --noEmit
| `src/content.ts` | détection des posts, extraction, masquage/annotation, relance du scroll infini |
| `src/sticker.ts` | la pastille d'activité Jev (Shadow DOM) |
| `src/background.ts` | questions, logique de décision, cache, appels API |
-| `src/jev.ts` | client typé minimal pour l'endpoint System One |
+| `src/jev.ts` | client typé minimal pour l'endpoint System One (TypeSafe direct ou via OpenRouter) |
+| `src/types.ts` | déclarations pures : réglages, verdicts, messagerie, contrat de questions/réponses Jev |
| `src/options.ts`, `public/options.*` | page de réglages |
Pour changer ce qui est filtré, modifie `buildQuestions()` et `decide()` dans
diff --git a/README.md b/README.md
index 0d43005..efbd016 100644
--- a/README.md
+++ b/README.md
@@ -8,7 +8,12 @@ Every post is sent to **Jev**, the typed classification model from
Engagement bait, self-promotion, off-topic and sponsored posts are collapsed into
a one-line bar, and every decision is explained right on the post.
-> Not affiliated with LinkedIn or TypeSafe AI. You need your own TypeSafe API key.
+> **Fork note:** this fork adds [OpenRouter](https://openrouter.ai) as an
+> alternative way to reach Jev — same model, billed to your OpenRouter account,
+> no TypeSafe account required.
+
+> Not affiliated with LinkedIn or TypeSafe AI. You need your own API key for the
+> provider you choose (TypeSafe direct, or OpenRouter).
## What it does
@@ -25,8 +30,9 @@ a one-line bar, and every decision is explained right on the post.
too short for LinkedIn's "load more" trigger; lkclean nudges it without moving
your reading position.
- **Cheap and fast** – one API call per post, all questions evaluated in parallel,
- verdicts cached per session. Roughly $0.042 per million input tokens: a scrolling
- session costs a fraction of a cent.
+ verdicts cached per session. Roughly $0.042 per million input tokens (output
+ tokens free): a scrolling session costs a fraction of a cent on either
+ provider.
@@ -42,9 +48,11 @@ a one-line bar, and every decision is explained right on the post.
front end (text in `[data-testid="expandable-text-box"]`), with fallbacks for the
legacy `data-id="urn:li:activity:…"` markup. Posts are identified by a hash of
their text, since LinkedIn no longer exposes a stable id.
-2. Author and text go to the service worker, which calls
- `POST https://api.typesafe.ai/v1/systemone` with a *state*
- `{ user_interests, post }` and several typed questions in a single request:
+2. Author and text go to the service worker, which calls the System One
+ endpoint — `POST https://api.typesafe.ai/v1/systemone` (TypeSafe direct) or
+ `POST https://openrouter.ai/api/v1/systemone` (via OpenRouter) — with a
+ *state* `{ user_interests, post }` and several typed questions in a single
+ request:
| Question | Type | Meaning |
| --- | --- | --- |
@@ -77,10 +85,13 @@ npm run build
Then load the `dist/` folder the same way.
-Click the extension icon to open the settings: paste your TypeSafe key
-(from [console.typesafe.ai](https://console.typesafe.ai)), list your interests
-(one per line), optionally list blocked topics, and adjust the thresholds.
-Reload LinkedIn.
+Click the extension icon to open the settings: pick your API provider
+(**TypeSafe direct** or **OpenRouter**), paste the matching key (from
+[console.typesafe.ai](https://console.typesafe.ai) — `ts-…` — or
+[openrouter.ai/settings/keys](https://openrouter.ai/settings/keys) — `sk-or-…`),
+list your interests (one per line), optionally list blocked topics, and adjust
+the thresholds. Set the model id to match the provider: `jev-latest` (TypeSafe)
+or `typesafe/jev-1.14` (OpenRouter). Reload LinkedIn.
After each rebuild, press ↻ on the extension card in `chrome://extensions`, then
reload the LinkedIn tab.
@@ -89,6 +100,7 @@ reload the LinkedIn tab.
| Setting | Default | Effect |
| --- | --- | --- |
+| API provider | TypeSafe direct | Where Jev is reached from; the matching key field is used |
| Interests | empty | If empty, only noise is filtered (bait, self-promo, sponsored) |
| Blocked topics | empty | Always hidden when Jev is ≥ 60 % confident |
| Noise threshold | 70 % | Hide when P(bait) or P(self-promo) is above it |
@@ -97,15 +109,14 @@ reload the LinkedIn tab.
| Minimum length | 40 chars | Shorter posts are never evaluated |
| Explain kept posts | on | The "Kept by Jev" line |
| Activity sticker | on | The floating Jev badge |
-| Model | `jev-latest` | Pin a version such as `jev-1.13.0` once your thresholds are tuned |
-
-The UI is currently in French.
+| Model | `jev-latest` | TypeSafe default; on OpenRouter use `typesafe/jev-1.14`. Pin one version once your thresholds are tuned |
## Privacy
- The author name and text of each post displayed in your feed are sent to the
- TypeSafe API for classification. Nothing else leaves your browser, and lkclean
- has no server or analytics of its own.
+ configured Jev provider (TypeSafe direct, or OpenRouter) for classification.
+ Nothing else leaves your browser, and lkclean has no server or analytics of
+ its own.
- The API key is stored in Chrome's synced storage (`chrome.storage.sync`).
- Verdicts are cached in `chrome.storage.session` and cleared when the browser
closes or the settings change.
@@ -122,7 +133,8 @@ npm run typecheck # tsc --noEmit
| `src/content.ts` | post detection, extraction, hiding/annotating, infinite-scroll nudge |
| `src/sticker.ts` | the Jev activity sticker (Shadow DOM) |
| `src/background.ts` | questions, decision logic, cache, API calls |
-| `src/jev.ts` | minimal typed client for the System One endpoint |
+| `src/jev.ts` | minimal typed client for the System One endpoint (TypeSafe direct or via OpenRouter) |
+| `src/types.ts` | pure declarations: settings, verdicts, messaging, Jev question/answer contract |
| `src/options.ts`, `public/options.*` | settings page |
To change what gets filtered, edit `buildQuestions()` and `decide()` in
diff --git a/package-lock.json b/package-lock.json
index 07c3f84..96c6915 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,22 +1,23 @@
{
"name": "lkclean",
- "version": "0.1.0",
+ "version": "0.2.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "lkclean",
- "version": "0.1.0",
+ "version": "0.2.1",
+ "license": "MIT",
"devDependencies": {
"@types/chrome": "^0.0.270",
- "esbuild": "^0.23.0",
+ "esbuild": "^0.28.1",
"typescript": "^5.5.0"
}
},
"node_modules/@esbuild/aix-ppc64": {
- "version": "0.23.1",
- "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.23.1.tgz",
- "integrity": "sha512-6VhYk1diRqrhBAqpJEdjASR/+WVRtfjpqKuNw11cLiaWpAT/Uu+nokB+UJnevzy/P9C/ty6AOe0dwueMrGh/iQ==",
+ "version": "0.28.2",
+ "resolved": "https://pkgs.safetycli.com/repository/public/npmjs/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz",
+ "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==",
"cpu": [
"ppc64"
],
@@ -31,9 +32,9 @@
}
},
"node_modules/@esbuild/android-arm": {
- "version": "0.23.1",
- "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.23.1.tgz",
- "integrity": "sha512-uz6/tEy2IFm9RYOyvKl88zdzZfwEfKZmnX9Cj1BHjeSGNuGLuMD1kR8y5bteYmwqKm1tj8m4cb/aKEorr6fHWQ==",
+ "version": "0.28.2",
+ "resolved": "https://pkgs.safetycli.com/repository/public/npmjs/@esbuild/android-arm/-/android-arm-0.28.2.tgz",
+ "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==",
"cpu": [
"arm"
],
@@ -48,9 +49,9 @@
}
},
"node_modules/@esbuild/android-arm64": {
- "version": "0.23.1",
- "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.23.1.tgz",
- "integrity": "sha512-xw50ipykXcLstLeWH7WRdQuysJqejuAGPd30vd1i5zSyKK3WE+ijzHmLKxdiCMtH1pHz78rOg0BKSYOSB/2Khw==",
+ "version": "0.28.2",
+ "resolved": "https://pkgs.safetycli.com/repository/public/npmjs/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz",
+ "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==",
"cpu": [
"arm64"
],
@@ -65,9 +66,9 @@
}
},
"node_modules/@esbuild/android-x64": {
- "version": "0.23.1",
- "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.23.1.tgz",
- "integrity": "sha512-nlN9B69St9BwUoB+jkyU090bru8L0NA3yFvAd7k8dNsVH8bi9a8cUAUSEcEEgTp2z3dbEDGJGfP6VUnkQnlReg==",
+ "version": "0.28.2",
+ "resolved": "https://pkgs.safetycli.com/repository/public/npmjs/@esbuild/android-x64/-/android-x64-0.28.2.tgz",
+ "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==",
"cpu": [
"x64"
],
@@ -82,9 +83,9 @@
}
},
"node_modules/@esbuild/darwin-arm64": {
- "version": "0.23.1",
- "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.23.1.tgz",
- "integrity": "sha512-YsS2e3Wtgnw7Wq53XXBLcV6JhRsEq8hkfg91ESVadIrzr9wO6jJDMZnCQbHm1Guc5t/CdDiFSSfWP58FNuvT3Q==",
+ "version": "0.28.2",
+ "resolved": "https://pkgs.safetycli.com/repository/public/npmjs/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz",
+ "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==",
"cpu": [
"arm64"
],
@@ -99,9 +100,9 @@
}
},
"node_modules/@esbuild/darwin-x64": {
- "version": "0.23.1",
- "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.23.1.tgz",
- "integrity": "sha512-aClqdgTDVPSEGgoCS8QDG37Gu8yc9lTHNAQlsztQ6ENetKEO//b8y31MMu2ZaPbn4kVsIABzVLXYLhCGekGDqw==",
+ "version": "0.28.2",
+ "resolved": "https://pkgs.safetycli.com/repository/public/npmjs/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz",
+ "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==",
"cpu": [
"x64"
],
@@ -116,9 +117,9 @@
}
},
"node_modules/@esbuild/freebsd-arm64": {
- "version": "0.23.1",
- "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.23.1.tgz",
- "integrity": "sha512-h1k6yS8/pN/NHlMl5+v4XPfikhJulk4G+tKGFIOwURBSFzE8bixw1ebjluLOjfwtLqY0kewfjLSrO6tN2MgIhA==",
+ "version": "0.28.2",
+ "resolved": "https://pkgs.safetycli.com/repository/public/npmjs/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz",
+ "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==",
"cpu": [
"arm64"
],
@@ -133,9 +134,9 @@
}
},
"node_modules/@esbuild/freebsd-x64": {
- "version": "0.23.1",
- "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.23.1.tgz",
- "integrity": "sha512-lK1eJeyk1ZX8UklqFd/3A60UuZ/6UVfGT2LuGo3Wp4/z7eRTRYY+0xOu2kpClP+vMTi9wKOfXi2vjUpO1Ro76g==",
+ "version": "0.28.2",
+ "resolved": "https://pkgs.safetycli.com/repository/public/npmjs/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz",
+ "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==",
"cpu": [
"x64"
],
@@ -150,9 +151,9 @@
}
},
"node_modules/@esbuild/linux-arm": {
- "version": "0.23.1",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.23.1.tgz",
- "integrity": "sha512-CXXkzgn+dXAPs3WBwE+Kvnrf4WECwBdfjfeYHpMeVxWE0EceB6vhWGShs6wi0IYEqMSIzdOF1XjQ/Mkm5d7ZdQ==",
+ "version": "0.28.2",
+ "resolved": "https://pkgs.safetycli.com/repository/public/npmjs/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz",
+ "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==",
"cpu": [
"arm"
],
@@ -167,9 +168,9 @@
}
},
"node_modules/@esbuild/linux-arm64": {
- "version": "0.23.1",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.23.1.tgz",
- "integrity": "sha512-/93bf2yxencYDnItMYV/v116zff6UyTjo4EtEQjUBeGiVpMmffDNUyD9UN2zV+V3LRV3/on4xdZ26NKzn6754g==",
+ "version": "0.28.2",
+ "resolved": "https://pkgs.safetycli.com/repository/public/npmjs/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz",
+ "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==",
"cpu": [
"arm64"
],
@@ -184,9 +185,9 @@
}
},
"node_modules/@esbuild/linux-ia32": {
- "version": "0.23.1",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.23.1.tgz",
- "integrity": "sha512-VTN4EuOHwXEkXzX5nTvVY4s7E/Krz7COC8xkftbbKRYAl96vPiUssGkeMELQMOnLOJ8k3BY1+ZY52tttZnHcXQ==",
+ "version": "0.28.2",
+ "resolved": "https://pkgs.safetycli.com/repository/public/npmjs/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz",
+ "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==",
"cpu": [
"ia32"
],
@@ -201,9 +202,9 @@
}
},
"node_modules/@esbuild/linux-loong64": {
- "version": "0.23.1",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.23.1.tgz",
- "integrity": "sha512-Vx09LzEoBa5zDnieH8LSMRToj7ir/Jeq0Gu6qJ/1GcBq9GkfoEAoXvLiW1U9J1qE/Y/Oyaq33w5p2ZWrNNHNEw==",
+ "version": "0.28.2",
+ "resolved": "https://pkgs.safetycli.com/repository/public/npmjs/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz",
+ "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==",
"cpu": [
"loong64"
],
@@ -218,9 +219,9 @@
}
},
"node_modules/@esbuild/linux-mips64el": {
- "version": "0.23.1",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.23.1.tgz",
- "integrity": "sha512-nrFzzMQ7W4WRLNUOU5dlWAqa6yVeI0P78WKGUo7lg2HShq/yx+UYkeNSE0SSfSure0SqgnsxPvmAUu/vu0E+3Q==",
+ "version": "0.28.2",
+ "resolved": "https://pkgs.safetycli.com/repository/public/npmjs/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz",
+ "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==",
"cpu": [
"mips64el"
],
@@ -235,9 +236,9 @@
}
},
"node_modules/@esbuild/linux-ppc64": {
- "version": "0.23.1",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.23.1.tgz",
- "integrity": "sha512-dKN8fgVqd0vUIjxuJI6P/9SSSe/mB9rvA98CSH2sJnlZ/OCZWO1DJvxj8jvKTfYUdGfcq2dDxoKaC6bHuTlgcw==",
+ "version": "0.28.2",
+ "resolved": "https://pkgs.safetycli.com/repository/public/npmjs/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz",
+ "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==",
"cpu": [
"ppc64"
],
@@ -252,9 +253,9 @@
}
},
"node_modules/@esbuild/linux-riscv64": {
- "version": "0.23.1",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.23.1.tgz",
- "integrity": "sha512-5AV4Pzp80fhHL83JM6LoA6pTQVWgB1HovMBsLQ9OZWLDqVY8MVobBXNSmAJi//Csh6tcY7e7Lny2Hg1tElMjIA==",
+ "version": "0.28.2",
+ "resolved": "https://pkgs.safetycli.com/repository/public/npmjs/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz",
+ "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==",
"cpu": [
"riscv64"
],
@@ -269,9 +270,9 @@
}
},
"node_modules/@esbuild/linux-s390x": {
- "version": "0.23.1",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.23.1.tgz",
- "integrity": "sha512-9ygs73tuFCe6f6m/Tb+9LtYxWR4c9yg7zjt2cYkjDbDpV/xVn+68cQxMXCjUpYwEkze2RcU/rMnfIXNRFmSoDw==",
+ "version": "0.28.2",
+ "resolved": "https://pkgs.safetycli.com/repository/public/npmjs/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz",
+ "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==",
"cpu": [
"s390x"
],
@@ -286,9 +287,9 @@
}
},
"node_modules/@esbuild/linux-x64": {
- "version": "0.23.1",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.23.1.tgz",
- "integrity": "sha512-EV6+ovTsEXCPAp58g2dD68LxoP/wK5pRvgy0J/HxPGB009omFPv3Yet0HiaqvrIrgPTBuC6wCH1LTOY91EO5hQ==",
+ "version": "0.28.2",
+ "resolved": "https://pkgs.safetycli.com/repository/public/npmjs/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz",
+ "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==",
"cpu": [
"x64"
],
@@ -302,10 +303,27 @@
"node": ">=18"
}
},
+ "node_modules/@esbuild/netbsd-arm64": {
+ "version": "0.28.2",
+ "resolved": "https://pkgs.safetycli.com/repository/public/npmjs/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz",
+ "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
"node_modules/@esbuild/netbsd-x64": {
- "version": "0.23.1",
- "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.23.1.tgz",
- "integrity": "sha512-aevEkCNu7KlPRpYLjwmdcuNz6bDFiE7Z8XC4CPqExjTvrHugh28QzUXVOZtiYghciKUacNktqxdpymplil1beA==",
+ "version": "0.28.2",
+ "resolved": "https://pkgs.safetycli.com/repository/public/npmjs/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz",
+ "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==",
"cpu": [
"x64"
],
@@ -320,9 +338,9 @@
}
},
"node_modules/@esbuild/openbsd-arm64": {
- "version": "0.23.1",
- "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.23.1.tgz",
- "integrity": "sha512-3x37szhLexNA4bXhLrCC/LImN/YtWis6WXr1VESlfVtVeoFJBRINPJ3f0a/6LV8zpikqoUg4hyXw0sFBt5Cr+Q==",
+ "version": "0.28.2",
+ "resolved": "https://pkgs.safetycli.com/repository/public/npmjs/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz",
+ "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==",
"cpu": [
"arm64"
],
@@ -337,9 +355,9 @@
}
},
"node_modules/@esbuild/openbsd-x64": {
- "version": "0.23.1",
- "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.23.1.tgz",
- "integrity": "sha512-aY2gMmKmPhxfU+0EdnN+XNtGbjfQgwZj43k8G3fyrDM/UdZww6xrWxmDkuz2eCZchqVeABjV5BpildOrUbBTqA==",
+ "version": "0.28.2",
+ "resolved": "https://pkgs.safetycli.com/repository/public/npmjs/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz",
+ "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==",
"cpu": [
"x64"
],
@@ -353,10 +371,27 @@
"node": ">=18"
}
},
+ "node_modules/@esbuild/openharmony-arm64": {
+ "version": "0.28.2",
+ "resolved": "https://pkgs.safetycli.com/repository/public/npmjs/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz",
+ "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
"node_modules/@esbuild/sunos-x64": {
- "version": "0.23.1",
- "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.23.1.tgz",
- "integrity": "sha512-RBRT2gqEl0IKQABT4XTj78tpk9v7ehp+mazn2HbUeZl1YMdaGAQqhapjGTCe7uw7y0frDi4gS0uHzhvpFuI1sA==",
+ "version": "0.28.2",
+ "resolved": "https://pkgs.safetycli.com/repository/public/npmjs/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz",
+ "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==",
"cpu": [
"x64"
],
@@ -371,9 +406,9 @@
}
},
"node_modules/@esbuild/win32-arm64": {
- "version": "0.23.1",
- "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.23.1.tgz",
- "integrity": "sha512-4O+gPR5rEBe2FpKOVyiJ7wNDPA8nGzDuJ6gN4okSA1gEOYZ67N8JPk58tkWtdtPeLz7lBnY6I5L3jdsr3S+A6A==",
+ "version": "0.28.2",
+ "resolved": "https://pkgs.safetycli.com/repository/public/npmjs/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz",
+ "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==",
"cpu": [
"arm64"
],
@@ -388,9 +423,9 @@
}
},
"node_modules/@esbuild/win32-ia32": {
- "version": "0.23.1",
- "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.23.1.tgz",
- "integrity": "sha512-BcaL0Vn6QwCwre3Y717nVHZbAa4UBEigzFm6VdsVdT/MbZ38xoj1X9HPkZhbmaBGUD1W8vxAfffbDe8bA6AKnQ==",
+ "version": "0.28.2",
+ "resolved": "https://pkgs.safetycli.com/repository/public/npmjs/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz",
+ "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==",
"cpu": [
"ia32"
],
@@ -405,9 +440,9 @@
}
},
"node_modules/@esbuild/win32-x64": {
- "version": "0.23.1",
- "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.23.1.tgz",
- "integrity": "sha512-BHpFFeslkWrXWyUPnbKm+xYYVYruCinGcftSBaa8zoF9hZO4BcSCFUvHVTtzpIY6YzUnYtuEhZ+C9iEXjxnasg==",
+ "version": "0.28.2",
+ "resolved": "https://pkgs.safetycli.com/repository/public/npmjs/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz",
+ "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==",
"cpu": [
"x64"
],
@@ -457,9 +492,9 @@
"license": "MIT"
},
"node_modules/esbuild": {
- "version": "0.23.1",
- "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.23.1.tgz",
- "integrity": "sha512-VVNz/9Sa0bs5SELtn3f7qhJCDPCF5oMEl5cO9/SSinpE9hbPVvxbd572HH5AKiP7WD8INO53GgfDDhRjkylHEg==",
+ "version": "0.28.2",
+ "resolved": "https://pkgs.safetycli.com/repository/public/npmjs/esbuild/-/esbuild-0.28.2.tgz",
+ "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
@@ -470,30 +505,32 @@
"node": ">=18"
},
"optionalDependencies": {
- "@esbuild/aix-ppc64": "0.23.1",
- "@esbuild/android-arm": "0.23.1",
- "@esbuild/android-arm64": "0.23.1",
- "@esbuild/android-x64": "0.23.1",
- "@esbuild/darwin-arm64": "0.23.1",
- "@esbuild/darwin-x64": "0.23.1",
- "@esbuild/freebsd-arm64": "0.23.1",
- "@esbuild/freebsd-x64": "0.23.1",
- "@esbuild/linux-arm": "0.23.1",
- "@esbuild/linux-arm64": "0.23.1",
- "@esbuild/linux-ia32": "0.23.1",
- "@esbuild/linux-loong64": "0.23.1",
- "@esbuild/linux-mips64el": "0.23.1",
- "@esbuild/linux-ppc64": "0.23.1",
- "@esbuild/linux-riscv64": "0.23.1",
- "@esbuild/linux-s390x": "0.23.1",
- "@esbuild/linux-x64": "0.23.1",
- "@esbuild/netbsd-x64": "0.23.1",
- "@esbuild/openbsd-arm64": "0.23.1",
- "@esbuild/openbsd-x64": "0.23.1",
- "@esbuild/sunos-x64": "0.23.1",
- "@esbuild/win32-arm64": "0.23.1",
- "@esbuild/win32-ia32": "0.23.1",
- "@esbuild/win32-x64": "0.23.1"
+ "@esbuild/aix-ppc64": "0.28.2",
+ "@esbuild/android-arm": "0.28.2",
+ "@esbuild/android-arm64": "0.28.2",
+ "@esbuild/android-x64": "0.28.2",
+ "@esbuild/darwin-arm64": "0.28.2",
+ "@esbuild/darwin-x64": "0.28.2",
+ "@esbuild/freebsd-arm64": "0.28.2",
+ "@esbuild/freebsd-x64": "0.28.2",
+ "@esbuild/linux-arm": "0.28.2",
+ "@esbuild/linux-arm64": "0.28.2",
+ "@esbuild/linux-ia32": "0.28.2",
+ "@esbuild/linux-loong64": "0.28.2",
+ "@esbuild/linux-mips64el": "0.28.2",
+ "@esbuild/linux-ppc64": "0.28.2",
+ "@esbuild/linux-riscv64": "0.28.2",
+ "@esbuild/linux-s390x": "0.28.2",
+ "@esbuild/linux-x64": "0.28.2",
+ "@esbuild/netbsd-arm64": "0.28.2",
+ "@esbuild/netbsd-x64": "0.28.2",
+ "@esbuild/openbsd-arm64": "0.28.2",
+ "@esbuild/openbsd-x64": "0.28.2",
+ "@esbuild/openharmony-arm64": "0.28.2",
+ "@esbuild/sunos-x64": "0.28.2",
+ "@esbuild/win32-arm64": "0.28.2",
+ "@esbuild/win32-ia32": "0.28.2",
+ "@esbuild/win32-x64": "0.28.2"
}
},
"node_modules/typescript": {
diff --git a/package.json b/package.json
index 57a0baa..e57a815 100644
--- a/package.json
+++ b/package.json
@@ -1,8 +1,8 @@
{
"name": "lkclean",
- "version": "0.1.0",
+ "version": "0.2.1",
"private": true,
- "description": "Chrome extension that hides low-value LinkedIn posts using Jev, TypeSafe AI's typed classification model",
+ "description": "Chrome extension that hides low-value LinkedIn posts using Jev, via TypeSafe direct or OpenRouter",
"scripts": {
"build": "node build.mjs",
"watch": "node build.mjs --watch",
@@ -11,7 +11,7 @@
},
"devDependencies": {
"@types/chrome": "^0.0.270",
- "esbuild": "^0.23.0",
+ "esbuild": "^0.28.1",
"typescript": "^5.5.0"
},
"repository": {
@@ -23,7 +23,7 @@
"chrome-extension",
"linkedin",
"feed-filter",
- "typesafe-ai",
+ "openrouter",
"jev",
"manifest-v3",
"typescript",
@@ -31,4 +31,4 @@
],
"author": "stefw",
"license": "MIT"
-}
+}
\ No newline at end of file
diff --git a/public/content.css b/public/content.css
index c554961..a1bda98 100644
--- a/public/content.css
+++ b/public/content.css
@@ -1,6 +1,6 @@
[data-lkclean-hidden] > :not(.lkclean-bar) { display: none !important; }
[data-lkclean-hidden] { padding: 0 !important; min-height: 0 !important; }
-/* Relance du scroll infini : pousse brièvement le marqueur de fin de fil hors de l'écran. */
+/* Infinite scroll kick: briefly pushes the end-of-feed marker off the screen. */
[data-lkclean-push] { margin-bottom: 8000px !important; }
.lkclean-bar, .lkclean-note {
@@ -16,7 +16,7 @@
}
.lkclean-bar { padding: 8px 14px; margin: 4px 0; background: var(--lk-fill); border-radius: 8px; }
.lkclean-bar--shown { margin-bottom: 8px; }
-/* Post gardé : bloc vert plein, textes blancs (contraste ≥ 5:1, identique en thème clair et sombre). */
+/* Kept post: solid green block, white text (contrast ≥ 5:1, identical in light and dark themes). */
.lkclean-note, .lkclean-note.lkclean--dark {
--lk-ink: #ffffff; --lk-muted: #ffffff; --lk-line: #ffffff73;
padding: 7px 12px; margin: 4px 0 6px; border-radius: 8px; background: #0f7a61;
diff --git a/public/manifest.json b/public/manifest.json
index 57ebd2e..93f3f15 100644
--- a/public/manifest.json
+++ b/public/manifest.json
@@ -1,19 +1,38 @@
{
"manifest_version": 3,
"name": "lkclean",
- "version": "0.1.0",
- "description": "Masque les posts LinkedIn inintéressants grâce à Jev (TypeSafe AI).",
- "permissions": ["storage"],
- "host_permissions": ["https://api.typesafe.ai/*"],
- "background": { "service_worker": "background.js", "type": "module" },
+ "version": "0.2.1",
+ "description": "Hides uninteresting LinkedIn posts, verified by Jev (TypeSafe direct or via OpenRouter).",
+ "permissions": [
+ "storage"
+ ],
+ "host_permissions": [
+ "https://api.typesafe.ai/*",
+ "https://openrouter.ai/*"
+ ],
+ "background": {
+ "service_worker": "background.js",
+ "type": "module"
+ },
"content_scripts": [
{
- "matches": ["https://www.linkedin.com/*"],
- "js": ["content.js"],
- "css": ["content.css"],
+ "matches": [
+ "https://www.linkedin.com/*"
+ ],
+ "js": [
+ "content.js"
+ ],
+ "css": [
+ "content.css"
+ ],
"run_at": "document_idle"
}
],
- "options_ui": { "page": "options.html", "open_in_tab": true },
- "action": { "default_title": "lkclean — options" }
-}
+ "options_ui": {
+ "page": "options.html",
+ "open_in_tab": true
+ },
+ "action": {
+ "default_title": "lkclean — settings"
+ }
+}
\ No newline at end of file
diff --git a/public/options.css b/public/options.css
index 6616ba8..f533267 100644
--- a/public/options.css
+++ b/public/options.css
@@ -19,7 +19,7 @@ section { padding: 24px 0; border-top: 1px solid var(--line); }
label { display: block; font-weight: 500; margin: 14px 0 6px; }
label:first-of-type { margin-top: 0; }
small { display: block; color: var(--muted); font-size: 13px; margin-top: 6px; }
-input[type="text"], input[type="password"], input[type="number"], textarea {
+input[type="text"], input[type="password"], input[type="number"], textarea, select {
width: 100%; padding: 9px 11px; font: inherit; color: var(--ink);
background: var(--field); border: 1px solid var(--line); border-radius: 6px;
}
@@ -40,6 +40,11 @@ button#save {
background: var(--accent); color: var(--accent-ink); cursor: pointer;
}
button#save:hover { filter: brightness(1.08); }
+button#test {
+ font: inherit; font-weight: 500; padding: 10px 18px; border: 1px solid var(--line); border-radius: 6px;
+ background: transparent; color: var(--ink); cursor: pointer;
+}
+button#test:hover { background: var(--field); }
#status { color: var(--muted); font-size: 14px; }
@media (prefers-color-scheme: dark) {
:root { --ink: #e8ecf1; --muted: #98a4b1; --line: #2c3742; --paper: #14191f; --field: #1c232b; --accent: #6fb3e0; --accent-ink: #0d1a24; }
diff --git a/public/options.html b/public/options.html
index 3b3742c..2325296 100644
--- a/public/options.html
+++ b/public/options.html
@@ -1,74 +1,88 @@
-
+
- lkclean — réglages
+ lkclean — settings
lkclean
-
Décide, post par post, ce qui mérite ta lecture. Chaque post du fil est évalué par Jev ; les verdicts sont mis en cache pour la session.
+
Decides, post by post, what is worth reading. Every feed post is evaluated by Jev; verdicts are cached for the session.
-
Accès à Jev
-
+
Jev access
+
+
+ Jev runs identically on both; pick where your key lives. OpenRouter needs no TypeSafe account.
+
+
- Récupérable sur console.typesafe.ai. Stockée dans le stockage synchronisé de Chrome, jamais injectée dans la page LinkedIn.
-
+ Get one at console.typesafe.ai. Stored in Chrome's synced storage, never injected into the LinkedIn page.
+
+
+
+ Get one at openrouter.ai/settings/keys (credits at openrouter.ai/credits). Only used when the provider above is OpenRouter.
+
+
+ jev-latest (TypeSafe) or typesafe/jev-1.14 (OpenRouter). Note: switching provider does not switch the model id for you.
-
Ce qui t'intéresse
-
-
- Laisse vide pour ne filtrer que le bruit (engagement bait, auto-promo, sponsorisé).
-
-
+
What you like
+
+
+ Leave empty to filter only the noise (engagement bait, self-promo, sponsored).
+
+
-
Sensibilité
+
Sensitivity
-
+
- Probabilité qu'un post soit de l'engagement bait ou de l'auto-promo. Plus haut = plus tolérant.
+ Probability a post is engagement bait or self-promo. Higher = more tolerant.
-
+
- Probabilité que le post touche un de tes centres d'intérêt. Plus bas = ne masque que ce dont Jev est sûr.
+ Probability the post touches one of your interests. Lower = only hide what Jev is confident about.
-
-
caractères
+
+
characters
-
Sur la page LinkedIn
+
On the LinkedIn page
diff --git a/src/background.ts b/src/background.ts
index e6a9867..50ea1b7 100644
--- a/src/background.ts
+++ b/src/background.ts
@@ -1,10 +1,13 @@
-import { askJev, type Answer, type Question } from "./jev";
+import { askJev } from "./jev";
import {
DEFAULT_SETTINGS,
+ type TestConnectionResponse,
+ type Answer,
type CallMeta,
type ClassifyResponse,
type Message,
type PostPayload,
+ type Question,
type Settings,
type UiSettings,
type Verdict,
@@ -14,16 +17,25 @@ interface Classified {
verdict: Verdict;
meta: CallMeta;
}
-const NO_CALL: CallMeta = { evaluated: false, cached: false, ms: 0, inputTokens: 0 };
+const NO_CALL: CallMeta = {
+ evaluated: false,
+ cached: false,
+ ms: 0,
+ inputTokens: 0,
+};
-// ---------- réglages ----------
+// ---------- settings ----------
-async function loadSettings(): Promise {
+let settingsCache: Settings | null = null;
+
+async function loadSettings(refresh: boolean = false): Promise {
+ if (!refresh && settingsCache) return settingsCache;
const stored = await chrome.storage.sync.get(DEFAULT_SETTINGS);
- return { ...DEFAULT_SETTINGS, ...(stored as Partial) };
+ settingsCache = { ...DEFAULT_SETTINGS, ...(stored as Partial) };
+ return settingsCache;
}
-// ---------- cache des verdicts (session) ----------
+// ---------- verdict cache (session) ----------
const memCache = new Map();
@@ -42,27 +54,29 @@ async function cacheSet(id: string, v: Verdict): Promise {
await chrome.storage.session.set({ [`v:${id}`]: v });
}
-// Un changement de réglages invalide les verdicts.
+// A settings change invalidates verdicts.
chrome.storage.onChanged.addListener((_, area) => {
if (area === "sync") {
memCache.clear();
+ settingsCache = null;
void chrome.storage.session.clear();
void chrome.action.setBadgeText({ text: "" });
}
});
-// ---------- questions posées à Jev ----------
+// ---------- questions asked of Jev ----------
function buildQuestions(s: Settings) {
- // Consignes en anglais : Jev est entraîné d'abord sur l'anglais (les posts, eux, restent dans leur langue).
+ // Instructions in English: Jev is trained primarily on English (the posts themselves stay in their language).
const q: Record = {
engagement_bait: {
type: "noul",
instructions:
"Is this LinkedIn post engagement bait: content designed primarily to trigger reactions rather than to inform?",
criteria: {
- true: "Rhetorical closing question (\"Agree?\", \"Thoughts?\", \"Qu'en pensez-vous ?\"), hollow emotional storytelling, clickbait hook, generic opinion with no substance, polls or 'comment X to get Y' mechanics",
- false: "The post delivers concrete information, analysis, data or first-hand experience the reader can use",
+ true: 'Rhetorical closing question ("Agree?", "Thoughts?", "What do you think?"), hollow emotional storytelling, clickbait hook, generic opinion with no substance, polls or \'comment X to get Y\' mechanics',
+ false:
+ "The post delivers concrete information, analysis, data or first-hand experience the reader can use",
},
},
self_promo: {
@@ -70,8 +84,9 @@ function buildQuestions(s: Settings) {
instructions:
"Is this post mainly self-promotion (personal announcement, humble-brag, celebrating a job/award/certification) with nothing useful for the reader?",
criteria: {
- true: "\"I'm thrilled to announce\", new job, certification, thanking the team, company PR, with no takeaway for the reader",
- false: "Even if it talks about the author, the post shares something the reader can act on or learn from",
+ true: '"I\'m thrilled to announce", new job, certification, thanking the team, company PR, with no takeaway for the reader',
+ false:
+ "Even if it talks about the author, the post shares something the reader can act on or learn from",
},
},
};
@@ -88,9 +103,11 @@ function buildQuestions(s: Settings) {
};
}
- // « Lequel ? » : matches_interests ne donne qu'une probabilité globale, pas le sujet concerné.
+ // "Which one?": matches_interests only gives a global probability, not the specific topic.
if (s.interests.length > 1) {
- const criteria: Record = { none: "The post does not substantially cover any of them" };
+ const criteria: Record = {
+ none: "The post does not substantially cover any of them",
+ };
for (const t of s.interests) criteria[t] = null;
q.top_interest = {
type: "choice",
@@ -101,7 +118,9 @@ function buildQuestions(s: Settings) {
}
if (s.blocked.length > 0) {
- const criteria: Record = { none: "None of the blocked topics" };
+ const criteria: Record = {
+ none: "None of the blocked topics",
+ };
for (const t of s.blocked) criteria[t] = null;
q.blocked_topic = {
type: "choice",
@@ -114,7 +133,23 @@ function buildQuestions(s: Settings) {
return q;
}
-// ---------- décision ----------
+function buildTestQuestions() {
+ const q: Record = {
+ test_question: {
+ type: "noul",
+ // Refer to state.text explicitly: "this string" alone has nothing to
+ // bind to and invites a degraded answer.
+ instructions: "In state.text: does this string contain a magic number?",
+ criteria: {
+ true: "Yes, it contains a magic number like 13, 42, or 7",
+ false: "No, it does not contain a magic number",
+ },
+ },
+ };
+ return q;
+}
+
+// ---------- decision ----------
function decide(s: Settings, answers: Record): Verdict {
const noul = (k: string) => {
@@ -130,13 +165,17 @@ function decide(s: Settings, answers: Record): Verdict {
if (promo !== undefined) scores.self_promo = promo;
if (match !== undefined) scores.matches_interests = match;
- // Un seul intérêt déclaré : pas besoin de demander lequel.
+ // Only one interest declared: no need to ask which.
const top = answers.top_interest;
let interest: string | undefined;
if (top?.type === "choice") {
if (top.choice !== "none") interest = top.choice;
scores.top_interest_confidence = top.confidence;
- } else if (s.interests.length === 1 && match !== undefined && match >= s.interestThreshold) {
+ } else if (
+ s.interests.length === 1 &&
+ match !== undefined &&
+ match >= s.interestThreshold
+ ) {
interest = s.interests[0];
}
@@ -144,17 +183,35 @@ function decide(s: Settings, answers: Record): Verdict {
if (blocked?.type === "choice") {
scores.blocked_confidence = blocked.confidence;
if (blocked.choice !== "none" && blocked.confidence >= 0.6) {
- return { hide: true, reason: "blocked_topic", detail: blocked.choice, interest, scores };
+ return {
+ hide: true,
+ reason: "blocked_topic",
+ detail: blocked.choice,
+ interest,
+ scores,
+ };
}
}
if (bait !== undefined && bait >= s.noiseThreshold) {
- return { hide: true, reason: "engagement_bait", detail: pct(bait), interest, scores };
+ return {
+ hide: true,
+ reason: "engagement_bait",
+ detail: pct(bait),
+ interest,
+ scores,
+ };
}
if (promo !== undefined && promo >= s.noiseThreshold) {
- return { hide: true, reason: "self_promo", detail: pct(promo), interest, scores };
+ return {
+ hide: true,
+ reason: "self_promo",
+ detail: pct(promo),
+ interest,
+ scores,
+ };
}
- // On ne masque pour hors-sujet que si Jev est vraiment sûr que ça ne colle pas.
+ // Only hide as off-topic when Jev is truly sure the post doesn't match.
if (match !== undefined && match < s.interestThreshold) {
return { hide: true, reason: "off_topic", detail: pct(match), scores };
}
@@ -165,7 +222,7 @@ const pct = (x: number) => `${Math.round(x * 100)} %`;
// ---------- classification ----------
-// Deux demandes simultanées pour le même post ne doivent coûter qu'un appel.
+// Two simultaneous requests for the same post must cost only one call.
const inflight = new Map>();
function classify(post: PostPayload): Promise {
@@ -173,7 +230,7 @@ function classify(post: PostPayload): Promise {
if (pending) return pending;
const p = classifyOnce(post)
.catch((e: unknown) => {
- // Signal visible sur l'icône : sans ça une clé absente/invalide passe inaperçue.
+ // Visible signal on the toolbar icon: without it a missing/invalid key goes unnoticed.
void chrome.action.setBadgeBackgroundColor({ color: "#c0392b" });
void chrome.action.setBadgeText({ text: "!" });
throw e;
@@ -183,6 +240,14 @@ function classify(post: PostPayload): Promise {
return p;
}
+function apiKeyForConfiguredProvider(s: Settings): string {
+ const apiKey = s.provider === "openrouter" ? s.openrouterApiKey : s.apiKey;
+ if (!apiKey) {
+ throw new Error("No API key configured for provider: " + s.provider);
+ }
+ return apiKey;
+}
+
async function classifyOnce(post: PostPayload): Promise {
const s = await loadSettings();
if (!s.enabled) return { verdict: { hide: false }, meta: NO_CALL };
@@ -190,43 +255,94 @@ async function classifyOnce(post: PostPayload): Promise {
if (post.sponsored && s.hideSponsored) {
return { verdict: { hide: true, reason: "sponsored" }, meta: NO_CALL };
}
- if (post.text.trim().length < s.minChars) return { verdict: { hide: false }, meta: NO_CALL };
+ if (post.text.trim().length < s.minChars)
+ return { verdict: { hide: false }, meta: NO_CALL };
const cached = await cacheGet(post.id);
- if (cached) return { verdict: cached, meta: { ...NO_CALL, evaluated: true, cached: true } };
-
- if (!s.apiKey) throw new Error("Clé API TypeSafe manquante (voir les options de l'extension).");
+ if (cached)
+ return {
+ verdict: cached,
+ meta: { ...NO_CALL, evaluated: true, cached: true },
+ };
const state = {
user_interests: s.interests,
post: { author: post.author, text: post.text.slice(0, 4000) },
};
+ const apiKey = apiKeyForConfiguredProvider(s);
+
const t0 = performance.now();
- const res = await askJev(s.apiKey, s.model, state, buildQuestions(s));
+ const res = await askJev(
+ apiKey,
+ s.provider,
+ s.model,
+ state,
+ buildQuestions(s),
+ );
const ms = Math.round(performance.now() - t0);
void chrome.action.setBadgeText({ text: "" });
const verdict = decide(s, res.answers as Record);
await cacheSet(post.id, verdict);
return {
verdict,
- meta: { evaluated: true, cached: false, ms, inputTokens: res.usage?.input_tokens ?? 0, model: res.model },
+ meta: {
+ evaluated: true,
+ cached: false,
+ ms,
+ inputTokens: res.usage?.input_tokens ?? 0,
+ model: res.model,
+ },
};
}
// ---------- messagerie ----------
+async function runSmokeTest(s: Settings): Promise {
+ const apiKey = apiKeyForConfiguredProvider(s);
+
+ const smokeState = { text: "I've got 42 bottles of porter and crack 7 of them. Sad." };
+ const res = await askJev(
+ apiKey,
+ s.provider,
+ s.model,
+ smokeState,
+ buildTestQuestions(),
+ );
+ return { ok: true, model: res.model };
+}
+
chrome.runtime.onMessage.addListener((msg: Message, _sender, sendResponse) => {
+ if (msg.type === "testConnection") {
+ // refresh=true: the options page just overwrote chrome.storage.sync
+ // (save-first); storage.onChanged's ordering vs this message dispatch
+ // is not guaranteed, so the cache must not be trusted here.
+ loadSettings(true)
+ .then((s) => runSmokeTest(s))
+ .then((r) => sendResponse(r))
+ .catch((e: unknown) =>
+ sendResponse({
+ ok: false,
+ error: e instanceof Error ? e.message : String(e),
+ } satisfies TestConnectionResponse),
+ );
+ return true;
+ }
if (msg.type === "classify") {
classify(msg.post)
- .then(({ verdict, meta }) => sendResponse({ ok: true, verdict, meta } satisfies ClassifyResponse))
+ .then(({ verdict, meta }) =>
+ sendResponse({ ok: true, verdict, meta } satisfies ClassifyResponse),
+ )
.catch((e: unknown) =>
- sendResponse({ ok: false, error: e instanceof Error ? e.message : String(e) } satisfies ClassifyResponse),
+ sendResponse({
+ ok: false,
+ error: e instanceof Error ? e.message : String(e),
+ } satisfies ClassifyResponse),
);
- return true; // réponse asynchrone
+ return true; // async response
}
if (msg.type === "getUiSettings") {
- // Le content script ne reçoit que ce dont il a besoin pour l'affichage : jamais la clé API.
+ // The content script only gets what it needs for display: never the API key.
loadSettings().then((s) =>
sendResponse({
enabled: s.enabled,
diff --git a/src/content.ts b/src/content.ts
index b76fbe3..197ced3 100644
--- a/src/content.ts
+++ b/src/content.ts
@@ -1,12 +1,12 @@
import { sticker } from "./sticker";
import type { ClassifyResponse, Message, PostPayload, Reason, UiSettings, Verdict } from "./types";
-// Sélecteurs LinkedIn : les classes sont obfusquées et bougent souvent, on vise
-// en priorité les attributs data-* (bien plus stables) puis des fallbacks.
+// LinkedIn selectors: classes are obfuscated and move often, so we target
+// data-* attributes first (much more stable), then fall back to classes.
const POST_SELECTORS = [
- // nouveau front React : le fil est une liste ARIA, un listitem par post
+ // new React front end: the feed is an ARIA list, one listitem per post
'[data-testid="mainFeed"] [role="listitem"]',
- // ancien front Ember
+ // old Ember front end
'div[data-id^="urn:li:activity"]',
'div[data-urn^="urn:li:activity"]',
"div.feed-shared-update-v2",
@@ -17,7 +17,7 @@ const TEXT_SELECTORS = [
".feed-shared-update-v2__description",
".feed-shared-inline-show-more-text",
];
-// Les commentaires réutilisent les mêmes composants de texte que les posts.
+// Comments reuse the same text components as posts.
const COMMENTS_SELECTOR = '[data-testid*="commentList"], [componentkey^="replaceableComment"]';
const AUTHOR_SELECTORS = [
".update-components-actor__title",
@@ -25,16 +25,16 @@ const AUTHOR_SELECTORS = [
];
const PROCESSED = "lkcleanProcessed";
-// Attribut plutôt que classe : React réécrit className à chaque rendu, pas les attributs qu'il ignore.
+// Attribute rather than class: React rewrites className on every render, not the attributes it ignores.
const HIDDEN_ATTR = "data-lkclean-hidden";
const MAX_CONCURRENT = 4;
const REASON_LABEL: Record = {
engagement_bait: "engagement bait",
- self_promo: "auto-promo",
- off_topic: "hors de tes centres d'intérêt",
- blocked_topic: "sujet masqué",
- sponsored: "sponsorisé",
+ self_promo: "self-promo",
+ off_topic: "off-topic",
+ blocked_topic: "blocked topic",
+ sponsored: "sponsored",
};
// ---------- extraction ----------
@@ -44,7 +44,7 @@ function firstText(root: Element, selectors: string[]): string {
for (const el of root.querySelectorAll(sel)) {
if (el.closest(COMMENTS_SELECTOR)) continue;
let t = el.innerText?.trim() ?? "";
- // Retire le libellé du bouton « … plus » inclus dans la boîte de texte.
+ // Strip the label of the "… more" button included in the text box.
const more = el.querySelector('[data-testid="expandable-text-button"]')?.innerText.trim();
if (more && t.endsWith(more)) t = t.slice(0, -more.length).trim();
if (t) return t;
@@ -53,11 +53,13 @@ function firstText(root: Element, selectors: string[]): string {
return "";
}
-// \b ne fonctionne pas après « é » : on borne avec des lookarounds Unicode.
-const SPONSORED_RE = /(?("span, p, a, div")) {
@@ -73,7 +75,7 @@ function hasSponsoredLabel(el: HTMLElement): boolean {
function authorOf(el: HTMLElement): string {
const named = firstText(el, AUTHOR_SELECTORS);
if (named) return named.split("\n")[0] ?? "";
- // Nouveau front : pas d'attribut stable, on prend le premier lien de profil/page qui porte un nom.
+ // New front end: no stable attribute, take the first profile/page link that carries a name.
for (const a of el.querySelectorAll('a[href*="/in/"], a[href*="/company/"]')) {
if (a.closest(COMMENTS_SELECTOR)) continue;
const name = a.innerText.trim().split("\n")[0];
@@ -83,17 +85,17 @@ function authorOf(el: HTMLElement): string {
}
function extract(el: HTMLElement): PostPayload | null {
- // Pas de repli sur innerText : sans vrai texte (image seule), on jugerait le post sur ses boutons.
+ // No innerText fallback: without real text (image-only post) we'd judge the post by its buttons.
const text = firstText(el, TEXT_SELECTORS);
// Le nouveau front n'expose plus l'URN (les componentkey sont des UUID par rendu) :
- // à défaut, on identifie le post par un hash de son texte, stable entre rechargements.
+ // as a fallback, identify the post by a hash of its text — stable across reloads.
const id =
el.getAttribute("data-id") ??
el.getAttribute("data-urn") ??
el.querySelector("[data-urn]")?.getAttribute("data-urn") ??
`h:${hash(text || el.innerText.slice(0, 300))}`;
const author = authorOf(el);
- // On ne regarde que l'en-tête (avant le texte du post), sinon « I got promoted » passe pour une pub.
+ // Only look at the header (before the post text), otherwise "I got promoted" would read as an ad.
const full = el.innerText;
const textStart = text ? full.indexOf(text.slice(0, 40)) : -1;
const head = (textStart > 0 ? full.slice(0, textStart) : full.slice(0, 300)).toLowerCase();
@@ -108,7 +110,7 @@ function hash(s: string): string {
return `${(h >>> 0).toString(36)}${s.length.toString(36)}`;
}
-// ---------- rendu ----------
+// ---------- rendering ----------
let ui: UiSettings = {
enabled: true,
@@ -118,7 +120,7 @@ let ui: UiSettings = {
interestThreshold: 0.35,
};
-const pct = (x: number) => `${Math.round(x * 100)} %`;
+const pct = (x: number) => `${Math.round(x * 100)}%`;
function node(tag: K, cls: string, text?: string): HTMLElementTagNameMap[K] {
const n = document.createElement(tag);
@@ -127,7 +129,7 @@ function node(tag: K, cls: string, text?:
return n;
}
-// LinkedIn gère son thème sombre par classe, pas par prefers-color-scheme : on mesure le fond réel.
+// LinkedIn handles dark mode with a class, not prefers-color-scheme: measure the actual background.
function isDarkPage(): boolean {
const m = getComputedStyle(document.body).backgroundColor.match(/\d+(\.\d+)?/g);
if (!m || m.length < 3) return false;
@@ -135,38 +137,38 @@ function isDarkPage(): boolean {
return 0.2126 * r + 0.7152 * g + 0.0722 * b < 110;
}
-/** Résumé en quelques mots d'un verdict, partagé entre la note du post et la pastille. */
+/** A verdict in a few words, shared between the post note and the sticker. */
function verdictLabel(v: Verdict): string {
- if (v.hide) return v.reason ? REASON_LABEL[v.reason] : "masqué";
+ if (v.hide) return v.reason ? REASON_LABEL[v.reason] : "hidden";
const match = v.scores?.matches_interests;
- if (match === undefined) return "peu de bruit";
- return v.interest ? `${v.interest} · ${pct(match)}` : `intérêt ${pct(match)}`;
+ if (match === undefined) return "low noise";
+ return v.interest ? `${v.interest} · ${pct(match)}` : `interest ${pct(match)}`;
}
type ChipState = "plain" | "near" | "over";
-// Où se situe chaque score par rapport au seuil qui fait masquer : franchi, frôlé (à 15 points), ou loin.
+// Where each score stands relative to the threshold that hides a post: crossed, near (within 15 points), or far.
function scoreStates(v: Verdict): { label: string; value: number; state: ChipState; hint: string }[] {
const sc = v.scores ?? {};
const out: ReturnType = [];
const m = sc.matches_interests;
if (m !== undefined) {
const state = m < ui.interestThreshold ? "over" : m < ui.interestThreshold + 0.15 ? "near" : "plain";
- const which = v.interest ? `Sujet reconnu : ${v.interest}. ` : "";
- out.push({ label: "intérêt", value: m, state, hint: `${which}Masqué sous ${pct(ui.interestThreshold)}` });
+ const which = v.interest ? `Recognized topic: ${v.interest}. ` : "";
+ out.push({ label: "interest", value: m, state, hint: `${which}Hides below ${pct(ui.interestThreshold)}` });
}
for (const [key, label] of [["engagement_bait", "bait"], ["self_promo", "promo"]] as const) {
const x = sc[key];
if (x === undefined) continue;
const state = x >= ui.noiseThreshold ? "over" : x > ui.noiseThreshold - 0.15 ? "near" : "plain";
- out.push({ label, value: x, state, hint: `Masqué à partir de ${pct(ui.noiseThreshold)}` });
+ out.push({ label, value: x, state, hint: `Hides at ${pct(ui.noiseThreshold)}` });
}
return out;
}
function scoreChips(v: Verdict): HTMLElement[] {
return scoreStates(v).map(({ label, value, state, hint }) => {
- // Sur un post masqué, seul le score qui a franchi le seuil ressort.
+ // On a hidden post, only the score that crossed its threshold stands out.
const shown = v.hide && state === "near" ? "plain" : state;
const c = node("span", `lkclean-chip lkclean-chip--${shown}`, `${label} ${pct(value)}`);
c.title = hint;
@@ -177,16 +179,16 @@ function scoreChips(v: Verdict): HTMLElement[] {
function noteKept(el: HTMLElement, v: Verdict): void {
if (!ui.showKeptReason || !v.scores || el.querySelector(":scope > .lkclean-note")) return;
const tight = scoreStates(v).some((x) => x.state === "near");
- const why = v.interest ?? (v.scores.matches_interests !== undefined ? "pertinent pour toi" : "peu de bruit");
+ const why = v.interest ?? (v.scores.matches_interests !== undefined ? "relevant to you" : "low noise");
const note = node("div", `lkclean-note${isDarkPage() ? " lkclean--dark" : ""}`);
const lead = node("span", "lkclean-note__lead");
lead.append(
node("span", `lkclean-note__dot${tight ? " lkclean-note__dot--near" : ""}`),
- node("b", "", tight ? "Gardé de justesse" : "Gardé par Jev"),
+ node("b", "", tight ? "Barely kept" : "Kept by Jev"),
" · ",
v.interest ? node("span", "lkclean-note__topic", why) : why,
);
- if (v.interest) lead.title = `Centre d'intérêt reconnu : ${v.interest}`;
+ if (v.interest) lead.title = `Recognized interest: ${v.interest}`;
note.append(lead, ...scoreChips(v));
el.prepend(note);
}
@@ -196,15 +198,15 @@ function hidePost(el: HTMLElement, v: Verdict): void {
el.querySelector(":scope > .lkclean-note")?.remove();
const bar = node("div", `lkclean-bar${isDarkPage() ? " lkclean--dark" : ""}`);
const reason = node("span", "lkclean-bar__reason");
- // verdictLabel() d'un post masqué = la raison ; le sujet reconnu est donné en complément.
- reason.append("Post masqué · ", node("b", "", verdictLabel(v)), v.detail ? ` · ${v.detail}` : "");
- if (v.interest) reason.append(" · sujet : ", node("span", "lkclean-note__topic", v.interest));
+ // verdictLabel() of a hidden post = the reason; the recognized topic is added as a complement.
+ reason.append("Post hidden · ", node("b", "", verdictLabel(v)), v.detail ? ` · ${v.detail}` : "");
+ if (v.interest) reason.append(" · topic: ", node("span", "lkclean-note__topic", v.interest));
reason.title = reason.textContent ?? "";
- const btn = node("button", "", "Afficher");
+ const btn = node("button", "", "Show");
btn.type = "button";
btn.addEventListener("click", () => {
const shown = el.toggleAttribute(HIDDEN_ATTR) === false;
- btn.textContent = shown ? "Re-masquer" : "Afficher";
+ btn.textContent = shown ? "Hide again" : "Show";
bar.classList.toggle("lkclean-bar--shown", shown);
});
bar.append(reason, ...scoreChips(v), btn);
@@ -224,12 +226,12 @@ let seen = 0;
const ANY_POST = POST_SELECTORS.join(",");
function enqueue(el: HTMLElement): void {
- // Les sélecteurs matchent aussi des conteneurs imbriqués : on ne traite que le plus externe.
+ // Selectors also match nested containers: only process the outermost.
const outer = el.parentElement?.closest(ANY_POST);
if (outer) return enqueue(outer);
if (el.dataset[PROCESSED]) return;
el.dataset[PROCESSED] = "1";
- if (seen++ === 0) console.info("[lkclean] premier post détecté");
+ if (seen++ === 0) console.info("[lkclean] first post detected");
lastArrival = Date.now();
kicks = 0;
queue.push(el);
@@ -252,8 +254,8 @@ async function process(el: HTMLElement): Promise {
const post = extract(el);
if (!post) return;
- // React insère souvent le conteneur avant son contenu : on libère le post pour qu'une
- // mutation ultérieure (arrivée du texte) le remette en file, dans la limite de MAX_ATTEMPTS.
+ // React often inserts the container before its content: release the post so
+ // a later mutation (the text arriving) queues it again, up to MAX_ATTEMPTS.
if (!post.text && !post.sponsored) {
const n = (attempts.get(el) ?? 0) + 1;
attempts.set(el, n);
@@ -267,13 +269,13 @@ async function process(el: HTMLElement): Promise {
res = await chrome.runtime.sendMessage({ type: "classify", post });
} catch (e) {
console.warn("[lkclean] message error", e);
- sticker.fail("Extension rechargée : actualise la page.");
+ sticker.fail("Extension reloaded: refresh the page.");
return;
}
if (!res.ok) {
console.warn("[lkclean]", res.error);
sticker.fail(res.error);
- if (/Clé API|Jev 40[13]/.test(res.error)) apiBroken = true;
+ if (/API key|Jev 40[13]/.test(res.error)) apiBroken = true;
return;
}
console.debug("[lkclean] verdict", post.author.slice(0, 40), res.verdict);
@@ -283,12 +285,13 @@ async function process(el: HTMLElement): Promise {
else noteKept(el, res.verdict);
}
-// ---------- relance du scroll infini ----------
+// ---------- infinite scroll kick ----------
-// LinkedIn charge la suite quand un marqueur en bas du fil *entre* dans l'écran. Si on masque
-// presque tout, le fil devient si court que le marqueur ne quitte plus l'écran : plus aucun
-// événement, le fil se fige. On le pousse donc brièvement hors champ (grande marge sous le
-// dernier post, invisible car sous le contenu) puis on le laisse revenir.
+// LinkedIn loads more when a marker at the bottom of the feed *enters* the
+// viewport. If we hide almost everything, the feed becomes so short the marker
+// never leaves the screen: no more events, the feed freezes. So we push it
+// briefly out of view (a large margin under the last post, invisible because
+// below the content) and then let it come back.
const PUSH_ATTR = "data-lkclean-push";
const KICK_IDLE_MS = 1500;
const MAX_KICKS = 8;
@@ -302,7 +305,7 @@ function lastPost(): HTMLElement | null {
function kickInfiniteScroll(): void {
if (document.hidden || running > 0 || queue.length > 0) return;
- if (!document.querySelector(`[${HIDDEN_ATTR}]`)) return; // on n'a rien raccourci : pas notre problème
+ if (!document.querySelector(`[${HIDDEN_ATTR}]`)) return; // we shortened nothing: not our problem
if (Date.now() - lastArrival < KICK_IDLE_MS * (1 + kicks) || kicks >= MAX_KICKS) return;
const last = lastPost();
if (!last || last.getBoundingClientRect().bottom > window.innerHeight * 1.5) return;
@@ -312,13 +315,13 @@ function kickInfiniteScroll(): void {
last.setAttribute(PUSH_ATTR, "");
setTimeout(() => {
last.removeAttribute(PUSH_ATTR);
- // Pour les chargeurs basés sur la position de scroll plutôt que sur un marqueur.
+ // For loaders driven by scroll position rather than by a marker.
window.dispatchEvent(new Event("scroll"));
document.dispatchEvent(new Event("scroll"));
}, 250);
}
-// ---------- observation du feed ----------
+// ---------- feed observation ----------
function scan(root: ParentNode): void {
for (const sel of POST_SELECTORS) {
@@ -337,7 +340,7 @@ const observer = new MutationObserver((mutations) => {
if (isPost(node)) enqueue(node);
else {
scan(node);
- // Contenu ajouté à l'intérieur d'un post déjà vu mais encore vide.
+ // Content added inside a post already seen but still empty.
const parent = node.closest(ANY_POST);
if (parent) enqueue(parent);
}
@@ -345,8 +348,8 @@ const observer = new MutationObserver((mutations) => {
}
});
-// Si rien n'est détecté sur le fil, on logue la structure de la page (attributs seulement,
-// aucun contenu) pour pouvoir réajuster les sélecteurs.
+// If nothing was detected on the feed, log the page structure (attributes only,
+// no content) so the selectors can be re-tuned.
function diagnose(): void {
if (seen > 0 || !location.pathname.startsWith("/feed")) return;
const tally = (attr: string) => {
@@ -365,7 +368,7 @@ function diagnose(): void {
return out;
};
const main = document.querySelector("main") ?? document.body;
- // Candidat : le plus petit ancêtre commun « raisonnable » d'un gros bloc de texte du fil.
+ // Candidate: the smallest "reasonable" common ancestor of a large text block on the feed.
const big = [...main.querySelectorAll("p, span[dir], div[dir]")].find((e) => e.innerText.length > 200);
let post: Element | null = big ?? null;
for (let i = 0; post && i < 12 && post.parentElement && post.parentElement !== main; i++) {
@@ -377,9 +380,9 @@ function diagnose(): void {
testIds: tally("data-testid"),
roles: tally("role"),
componentKeys: tally("componentkey").slice(0, 15),
- firstPost: post ? outline(post, 0, []) : "aucun candidat",
+ firstPost: post ? outline(post, 0, []) : "no candidate",
};
- console.warn("[lkclean] DIAGNOSTIC — aucun post détecté, copie ce bloc :\n" + JSON.stringify(report, null, 1));
+ console.warn("[lkclean] DIAGNOSTIC — no post detected, copy this block:\n" + JSON.stringify(report, null, 1));
}
async function loadUi(): Promise {
@@ -387,7 +390,7 @@ async function loadUi(): Promise {
const got = await chrome.runtime.sendMessage({ type: "getUiSettings" });
if (got) ui = got;
} catch {
- // service worker indisponible : on garde les valeurs par défaut
+ // service worker unavailable: keep the defaults
}
if (ui.enabled && ui.showSticker) sticker.mount();
else sticker.unmount();
@@ -400,7 +403,7 @@ async function start(): Promise {
});
scan(document);
observer.observe(document.body, { childList: true, subtree: true });
- console.info("[lkclean] actif");
+ console.info("[lkclean] active");
setTimeout(diagnose, 6000);
setInterval(kickInfiniteScroll, 1000);
}
diff --git a/src/jev.ts b/src/jev.ts
index 82e1394..5402660 100644
--- a/src/jev.ts
+++ b/src/jev.ts
@@ -1,62 +1,27 @@
-// Client minimal pour l'endpoint System One de TypeSafe.
-// Réf. : https://docs.typesafe.ai/api
+import type { JevResponse, Provider, Question } from "./types";
-export type NoulQuestion = {
- type: "noul";
- instructions: string;
- criteria?: { true?: string; false?: string };
-};
-export type ChoiceQuestion = {
- type: "choice";
- instructions: string;
- criteria: Record;
-};
-export type ScoreQuestion = {
- type: "score";
- instructions: string;
- criteria: string[];
-};
-export type Question = NoulQuestion | ChoiceQuestion | ScoreQuestion;
+// Minimal client for the System One endpoint — TypeSafe direct or via
+// OpenRouter, as chosen by the user (see `Provider` in ./types).
+// Ref.: https://docs.typesafe.ai/api + https://openrouter.ai/docs/guides/community/jev-tutorial
-export type NoulAnswer = { type: "noul"; noul: number };
-export type ChoiceAnswer = {
- type: "choice";
- choice: string;
- probabilities: Record;
- confidence: number;
-};
-export type ScoreAnswer = {
- type: "score";
- score: number;
- legend: Record;
- probabilities: Record;
- confidence: number;
+// Completeness is compiler-enforced: adding a value to the Provider union
+// without an endpoint here fails typecheck.
+const ENDPOINTS: Record = {
+ typesafe: "https://api.typesafe.ai/v1/systemone",
+ openrouter: "https://openrouter.ai/api/v1/systemone",
};
-export type Answer = NoulAnswer | ChoiceAnswer | ScoreAnswer;
-
-type AnswerFor = Q extends NoulQuestion
- ? NoulAnswer
- : Q extends ChoiceQuestion
- ? ChoiceAnswer
- : ScoreAnswer;
-
-export interface JevResponse> {
- model: string;
- answers: { [K in keyof Q]: AnswerFor };
- usage: { input_tokens: number; output_tokens: number };
-}
-
-const ENDPOINT = "https://api.typesafe.ai/v1/systemone";
const TIMEOUT_MS = 10_000;
+const RETRYABLE_STATUSES = new Set([429, 502, 503, 524, 529]);
export async function askJev>(
apiKey: string,
+ provider: Provider,
model: string,
state: unknown,
questions: Q,
attempt = 0,
): Promise> {
- const res = await fetch(ENDPOINT, {
+ const res = await fetch(ENDPOINTS[provider], {
method: "POST",
headers: {
Authorization: `Bearer ${apiKey}`,
@@ -66,11 +31,12 @@ export async function askJev>(
signal: AbortSignal.timeout(TIMEOUT_MS),
});
- if ((res.status === 429 || res.status === 529) && attempt < 3) {
+ if (RETRYABLE_STATUSES.has(res.status) && attempt < 3) {
const retryAfter = Number(res.headers.get("retry-after"));
- const delay = retryAfter > 0 ? Math.min(retryAfter * 1000, 10_000) : 400 * 2 ** attempt;
+ const delay =
+ retryAfter > 0 ? Math.min(retryAfter * 1000, 10_000) : 400 * 2 ** attempt;
await new Promise((r) => setTimeout(r, delay));
- return askJev(apiKey, model, state, questions, attempt + 1);
+ return askJev(apiKey, provider, model, state, questions, attempt + 1);
}
if (!res.ok) {
const body = await res.text().catch(() => "");
diff --git a/src/options.ts b/src/options.ts
index 5d09237..a6ee7aa 100644
--- a/src/options.ts
+++ b/src/options.ts
@@ -1,10 +1,18 @@
-import { DEFAULT_SETTINGS, type Settings } from "./types";
-
-const $ = (id: string) => document.getElementById(id) as T;
+import {
+ DEFAULT_SETTINGS,
+ type Settings,
+ isProvider,
+ type TestConnectionResponse,
+ type Message,
+} from "./types";
+const $ = (id: string) =>
+ document.getElementById(id) as T;
const fields = {
enabled: $("enabled"),
apiKey: $("apiKey"),
+ provider: $("provider"),
+ openrouterKey: $("openrouterKey"),
model: $("model"),
interests: $("interests"),
blocked: $("blocked"),
@@ -19,13 +27,19 @@ const noiseOut = $("noiseOut");
const interestOut = $("interestOut");
const status = $("status");
-const lines = (s: string) => s.split("\n").map((l) => l.trim()).filter(Boolean);
-const pct = (v: string) => `${Math.round(Number(v) * 100)} %`;
+const lines = (s: string) =>
+ s
+ .split("\n")
+ .map((l) => l.trim())
+ .filter(Boolean);
+const pct = (v: string) => `${Math.round(Number(v) * 100)}%`;
function render(s: Settings): void {
fields.enabled.checked = s.enabled;
fields.apiKey.value = s.apiKey;
fields.model.value = s.model;
+ fields.provider.value = s.provider;
+ fields.openrouterKey.value = s.openrouterApiKey;
fields.interests.value = s.interests.join("\n");
fields.blocked.value = s.blocked.join("\n");
fields.noiseThreshold.value = String(s.noiseThreshold);
@@ -42,6 +56,10 @@ function read(): Settings {
return {
enabled: fields.enabled.checked,
apiKey: fields.apiKey.value.trim(),
+ provider: isProvider(fields.provider.value)
+ ? fields.provider.value
+ : DEFAULT_SETTINGS.provider,
+ openrouterApiKey: fields.openrouterKey.value.trim(),
model: fields.model.value.trim() || DEFAULT_SETTINGS.model,
interests: lines(fields.interests.value),
blocked: lines(fields.blocked.value),
@@ -61,11 +79,58 @@ async function load(): Promise {
async function save(): Promise {
await chrome.storage.sync.set(read());
- status.textContent = "Enregistré. Les nouveaux posts suivent ces réglages ; recharge LinkedIn pour réévaluer le fil.";
- setTimeout(() => (status.textContent = ""), 4000);
+ showStatus(
+ "Saved. New posts follow these settings; reload LinkedIn to re-evaluate the feed.",
+ 4000,
+ );
}
-fields.noiseThreshold.addEventListener("input", () => (noiseOut.value = pct(fields.noiseThreshold.value)));
-fields.interestThreshold.addEventListener("input", () => (interestOut.value = pct(fields.interestThreshold.value)));
+let inFlight = false;
+
+// One shared timer: a new message cancels the previous dwell instead of
+// fighting it, and callers don't await the display (finally below resets
+// inFlight right away instead of pinning the button for the full dwell).
+let statusTimer: number | undefined;
+function showStatus(text: string, ms: number): void {
+ status.textContent = text;
+ clearTimeout(statusTimer);
+ statusTimer = setTimeout(() => (status.textContent = ""), ms);
+}
+
+async function testConnection(): Promise {
+ if (inFlight) return;
+ inFlight = true;
+ try {
+ const state = read();
+ await chrome.storage.sync.set(state);
+ const res = await chrome.runtime.sendMessage({ type: "testConnection" });
+
+ if (!res) {
+ showStatus("No response from the background worker — try again.", 6000);
+ return;
+ }
+
+ if (res.ok) {
+ showStatus(`OK — saved. Model ${res.model}`, 6000);
+ } else {
+ showStatus(`Error: ${res.error}`, 6000);
+ }
+ } catch (e) {
+ showStatus(`Unexpected error: ${e instanceof Error ? e.message : String(e)}`, 6000);
+ } finally {
+ inFlight = false;
+ }
+}
+
+fields.noiseThreshold.addEventListener(
+ "input",
+ () => (noiseOut.value = pct(fields.noiseThreshold.value)),
+);
+fields.interestThreshold.addEventListener(
+ "input",
+ () => (interestOut.value = pct(fields.interestThreshold.value)),
+);
$("save").addEventListener("click", () => void save());
+$("test").addEventListener("click", () => void testConnection());
+
void load();
diff --git a/src/sticker.ts b/src/sticker.ts
index b23b08f..c2c4038 100644
--- a/src/sticker.ts
+++ b/src/sticker.ts
@@ -1,9 +1,9 @@
import type { CallMeta, Verdict } from "./types";
-// Pastille flottante « activité Jev ». Vit dans un Shadow DOM accroché à :
-// hors de l'arbre React de LinkedIn et à l'abri de son CSS.
+// Floating "Jev activity" sticker. Lives in a Shadow DOM attached to :
+// outside LinkedIn's React tree and shielded from its CSS.
-/** Jev 1.13 : 0,042 $ par million de tokens en entrée, sortie gratuite. */
+/** Jev 1.14: $0.042 per million input tokens; output tokens are free. */
const USD_PER_TOKEN = 0.042 / 1_000_000;
const SPARK_MAX = 32;
const RECENT_MAX = 5;
@@ -73,7 +73,7 @@ const CSS = `
.spark { display: flex; align-items: flex-end; gap: 2px; height: 30px; margin-bottom: 12px; }
.spark i { flex: 1; max-width: 8px; min-height: 2px; border-radius: 2px 2px 0 0; background: #4b5763; }
.spark i.h { background: #38c6a4; }
-.spark:empty::after { content: "en attente du premier appel…"; color: #6f7b87; font-size: 11.5px; align-self: center; }
+.spark:empty::after { content: "waiting for the first call…"; color: #6f7b87; font-size: 11.5px; align-self: center; }
.recent { list-style: none; margin: 0 0 10px; padding: 0; display: grid; grid-template-columns: minmax(0, 1fr); gap: 4px; }
.recent li { display: flex; gap: 8px; justify-content: space-between; min-width: 0; }
.recent li span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
@@ -113,16 +113,16 @@ function build(): void {
panel.id = "panel";
const head = el("div", "head");
- head.append(el("strong", "", "Activité Jev"), (refs.model = el("span", "muted")));
+ head.append(el("strong", "", "Jev activity"), (refs.model = el("span", "muted")));
const grid = el("div", "grid");
for (const [key, label] of [
- ["analysed", "Analysés"],
- ["hidden", "Masqués"],
- ["inflight", "En cours"],
+ ["analysed", "Analysed"],
+ ["hidden", "Hidden"],
+ ["inflight", "In flight"],
["tokens", "Tokens"],
- ["cost", "Coût"],
- ["latency", "Latence"],
+ ["cost", "Cost"],
+ ["latency", "Latency"],
] as const) {
const s = el("div", "stat");
s.append(el("span", "", label), (refs[key] = el("b", "", "0")));
@@ -130,10 +130,10 @@ function build(): void {
}
refs.spark = el("div", "spark");
- refs.spark.title = "Latence des derniers appels (vert = post masqué)";
+ refs.spark.title = "Latency of recent calls (green = post hidden)";
refs.recent = el("ul", "recent");
refs.err = el("p", "err");
- const opts = el("button", "link", "Réglages");
+ const opts = el("button", "link", "Settings");
opts.type = "button";
opts.addEventListener("click", () => void chrome.runtime.sendMessage({ type: "openOptions" }));
panel.append(head, grid, refs.spark, refs.recent, refs.err, opts);
@@ -161,7 +161,7 @@ function setText(node: HTMLElement | undefined, text: string, bump = false): voi
node.textContent = text;
if (bump) {
node.classList.remove("bump");
- void node.offsetWidth; // relance l'animation
+ void node.offsetWidth; // restart the animation
node.classList.add("bump");
}
}
@@ -172,17 +172,17 @@ function render(): void {
refs.wrap?.classList.toggle("busy", s.inflight > 0);
refs.wrap?.classList.toggle("error", s.error !== "");
- const share = s.analysed ? ` (${Math.round((s.hidden / s.analysed) * 100)} %)` : "";
- setText(refs.summary, s.error ? "en panne" : `${s.analysed} lus · ${s.hidden} masqués`, true);
+ const share = s.analysed ? ` (${Math.round((s.hidden / s.analysed) * 100)}%)` : "";
+ setText(refs.summary, s.error ? "down" : `${s.analysed} read · ${s.hidden} hidden`, true);
setText(refs.analysed, String(s.analysed));
setText(refs.hidden, `${s.hidden}${share}`);
setText(refs.inflight, String(s.inflight));
setText(refs.tokens, s.tokens >= 10_000 ? `${(s.tokens / 1000).toFixed(1)}k` : String(s.tokens));
- setText(refs.cost, `${(s.tokens * USD_PER_TOKEN).toFixed(4).replace(".", ",")} $`);
+ setText(refs.cost, `$${(s.tokens * USD_PER_TOKEN).toFixed(4)}`);
setText(refs.latency, s.calls ? `${Math.round(s.msTotal / s.calls)} ms` : "—");
setText(refs.model, s.model);
setText(refs.err, s.error);
- if (refs.cost) refs.cost.title = `${s.calls} appels · ${s.cacheHits} verdicts servis par le cache`;
+ if (refs.cost) refs.cost.title = `${s.calls} calls · ${s.cacheHits} verdicts served from the cache`;
if (refs.spark) {
const max = Math.max(1, ...s.spark.map((p) => p.ms));
@@ -216,7 +216,7 @@ export const sticker = {
stats.inflight++;
render();
},
- /** Fin d'un passage, qu'il y ait eu appel à Jev ou non. */
+ /** End of a pass, whether or not a Jev call happened. */
settle(author: string, verdict: Verdict, meta: CallMeta, label: string): void {
stats.inflight = Math.max(0, stats.inflight - 1);
stats.error = "";
diff --git a/src/types.ts b/src/types.ts
index 8eba1c7..b7d18e1 100644
--- a/src/types.ts
+++ b/src/types.ts
@@ -1,34 +1,110 @@
+/** Where Jev is served from; drives the endpoint map in jev.ts. */
+// types.ts hosts pure declarations plus the provider checklist — the one
+// deliberate runtime exception: types alone cannot enforce "listed ↔ usable".
+export const PROVIDERS = ["typesafe", "openrouter"] as const;
+export type Provider = (typeof PROVIDERS)[number];
+
+// Zero casts on purpose: an equality loop needs no cajoling of the compiler.
+export function isProvider(v: string): v is Provider {
+ return PROVIDERS.some((p) => p === v);
+}
+
+// ---------- Jev (System One) API contract ----------
+// What we ask and what comes back. Pure declarations: jev.ts only transports
+// them, background.ts (decide()) only branch on them.
+
+export type NoulQuestion = {
+ type: "noul";
+ instructions: string;
+ criteria?: { true?: string; false?: string };
+};
+export type ChoiceQuestion = {
+ type: "choice";
+ instructions: string;
+ criteria: Record;
+};
+export type ScoreQuestion = {
+ type: "score";
+ instructions: string;
+ criteria: string[];
+};
+export type Question = NoulQuestion | ChoiceQuestion | ScoreQuestion;
+
+export type NoulAnswer = { type: "noul"; noul: number };
+export type ChoiceAnswer = {
+ type: "choice";
+ choice: string;
+ probabilities: Record;
+ confidence: number;
+};
+export type ScoreAnswer = {
+ type: "score";
+ score: number;
+ legend: Record;
+ probabilities: Record;
+ confidence: number;
+};
+export type Answer = NoulAnswer | ChoiceAnswer | ScoreAnswer;
+
+type AnswerFor = Q extends NoulQuestion
+ ? NoulAnswer
+ : Q extends ChoiceQuestion
+ ? ChoiceAnswer
+ : ScoreAnswer;
+
+export interface JevResponse> {
+ model: string;
+ answers: { [K in keyof Q]: AnswerFor };
+ usage: { input_tokens: number; output_tokens: number };
+}
+
export interface Settings {
enabled: boolean;
+ /** TypeSafe direct API key. */
apiKey: string;
+ /**
+ * v1 stance: the model id does NOT follow a provider switch; the user keeps
+ * it up to date (the sticker shows which model actually served the verdicts).
+ * Auto-suggesting a model per provider = stretch goal.
+ */
model: string;
- /** Une ligne par centre d'intérêt, ex. "IA appliquée", "product management" */
+ provider: Provider;
+ openrouterApiKey: string;
+ /** One interest per line, e.g. "applied AI", "product management" */
interests: string[];
- /** Sujets à masquer d'office, ex. "offres d'emploi", "crypto" */
+ /** Topics to always hide, e.g. "job offers", "crypto" */
blocked: string[];
- /** Masquer si P(correspond aux intérêts) < seuil */
+ /** Hide if P(matches interests) < threshold */
interestThreshold: number;
- /** Masquer si P(engagement bait / auto-promo) > seuil */
+ /** Hide if P(engagement bait / self-promo) > threshold */
noiseThreshold: number;
- /** Ne jamais masquer un post de moins de N caractères (souvent une image seule) */
+ /** Never hide a post shorter than N characters (often an image alone) */
minChars: number;
hideSponsored: boolean;
- /** Afficher, sur les posts gardés, pourquoi Jev les a laissés passer */
+ /** Show, on kept posts, why Jev let them pass */
showKeptReason: boolean;
- /** Pastille flottante « activité Jev » sur la page LinkedIn */
+ /** Floating "Jev activity" sticker on the LinkedIn page */
showSticker: boolean;
}
-/** Sous-ensemble des réglages exposé au content script (jamais la clé API). */
+/** Subset of the settings exposed to the content script (never the API key). */
export type UiSettings = Pick<
Settings,
- "enabled" | "showKeptReason" | "showSticker" | "noiseThreshold" | "interestThreshold"
+ | "enabled"
+ | "showKeptReason"
+ | "showSticker"
+ | "noiseThreshold"
+ | "interestThreshold"
>;
export const DEFAULT_SETTINGS: Settings = {
enabled: true,
apiKey: "",
+ /** v1 stance: the model id does NOT follow the provider switch; verified
+ * after each change. Fixing grudges mechanically = stretch goal. */
model: "jev-latest",
+ provider: "typesafe",
+ openrouterApiKey: "",
interests: [],
blocked: [],
interestThreshold: 0.35,
@@ -50,9 +126,9 @@ export interface Verdict {
hide: boolean;
reason?: Reason;
detail?: string;
- /** Centre d'intérêt de l'utilisateur que le post recoupe le plus, s'il y en a un */
+ /** The user's interest the post matches most, if any */
interest?: string;
- /** scores bruts, utiles pour le debug */
+ /** raw scores, useful for debugging */
scores?: Record;
}
@@ -63,9 +139,9 @@ export interface PostPayload {
sponsored: boolean;
}
-/** Ce qu'a coûté le verdict : sert à la pastille d'activité. */
+/** What the verdict cost: powers the activity sticker. */
export interface CallMeta {
- /** false si aucun appel à Jev n'a été nécessaire (sponsorisé, trop court, filtrage coupé) */
+ /** false when no Jev call was needed (sponsored, too short, filtering off) */
evaluated: boolean;
cached: boolean;
ms: number;
@@ -76,8 +152,13 @@ export interface CallMeta {
export type Message =
| { type: "classify"; post: PostPayload }
| { type: "getUiSettings" }
- | { type: "openOptions" };
+ | { type: "openOptions" }
+ | { type: "testConnection" };
export type ClassifyResponse =
| { ok: true; verdict: Verdict; meta: CallMeta }
| { ok: false; error: string };
+
+export type TestConnectionResponse =
+ | { ok: true; model: string }
+ | { ok: false; error: string };