@jackwener/opencli 1.7.12 → 1.7.13
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +8 -7
- package/README.zh-CN.md +9 -8
- package/cli-manifest.json +12194 -6843
- package/clis/1point3acres/digest.js +35 -0
- package/clis/1point3acres/forum.js +51 -0
- package/clis/1point3acres/forums.js +44 -0
- package/clis/1point3acres/hot.js +35 -0
- package/clis/1point3acres/latest.js +35 -0
- package/clis/1point3acres/notifications.js +64 -0
- package/clis/1point3acres/search.js +71 -0
- package/clis/1point3acres/thread.js +117 -0
- package/clis/1point3acres/user.js +77 -0
- package/clis/1point3acres/utils.js +247 -0
- package/clis/_shared/desktop-commands.js +4 -0
- package/clis/aibase/news.js +110 -0
- package/clis/aibase/news.test.js +59 -0
- package/clis/amazon/discussion.test.js +1 -28
- package/clis/antigravity/watch.js +3 -2
- package/clis/arxiv/author.js +44 -0
- package/clis/baidu-scholar/search.js +0 -1
- package/clis/bbc/topic.js +57 -0
- package/clis/bbc/utils.js +79 -0
- package/clis/chaoxing/assignments.js +1 -1
- package/clis/chaoxing/exams.js +1 -1
- package/clis/chatgpt/ask.js +57 -0
- package/clis/chatgpt/commands.test.js +45 -0
- package/clis/chatgpt/detail.js +46 -0
- package/clis/chatgpt/history.js +39 -0
- package/clis/chatgpt/image.js +12 -11
- package/clis/chatgpt/image.test.js +23 -0
- package/clis/chatgpt/new.js +25 -0
- package/clis/chatgpt/read.js +43 -0
- package/clis/chatgpt/send.js +46 -0
- package/clis/chatgpt/status.js +29 -0
- package/clis/chatgpt/utils.js +294 -4
- package/clis/chatgpt/utils.test.js +13 -0
- package/clis/chatgpt-app/ask.js +6 -3
- package/clis/chatwise/ask.js +16 -43
- package/clis/chatwise/composer.test.js +186 -0
- package/clis/chatwise/send.js +2 -24
- package/clis/chatwise/utils.js +143 -0
- package/clis/claude/ask.js +1 -1
- package/clis/claude/detail.js +1 -0
- package/clis/claude/history.js +1 -0
- package/clis/claude/new.js +1 -0
- package/clis/claude/read.js +1 -0
- package/clis/claude/send.js +1 -0
- package/clis/claude/status.js +1 -0
- package/clis/codex/ask.js +15 -9
- package/clis/codex/history.js +16 -33
- package/clis/codex/projects.js +28 -0
- package/clis/codex/read.js +10 -4
- package/clis/codex/send.js +10 -3
- package/clis/codex/sidebar.js +356 -0
- package/clis/codex/sidebar.test.js +329 -0
- package/clis/coingecko/categories.js +75 -0
- package/clis/coingecko/coin.js +107 -0
- package/clis/coingecko/coingecko.test.js +109 -0
- package/clis/coingecko/derivatives.js +84 -0
- package/clis/coingecko/exchanges.js +74 -0
- package/clis/coingecko/global.js +71 -0
- package/clis/coingecko/top.js +64 -0
- package/clis/coingecko/trending.js +55 -0
- package/clis/coupang/add-to-cart.js +21 -13
- package/clis/coupang/coupang.test.js +159 -0
- package/clis/coupang/product.js +257 -0
- package/clis/coupang/search.js +38 -16
- package/clis/coupang/utils.js +55 -1
- package/clis/crates/crate.js +62 -0
- package/clis/crates/search.js +44 -0
- package/clis/crates/utils.js +72 -0
- package/clis/ctrip/ctrip.test.js +234 -0
- package/clis/ctrip/hotel-suggest.js +45 -0
- package/clis/ctrip/search.js +22 -68
- package/clis/ctrip/utils.js +175 -0
- package/clis/cursor/ask.js +6 -3
- package/clis/dblp/author.js +133 -0
- package/clis/dblp/venue.js +64 -0
- package/clis/deepseek/ask.js +12 -7
- package/clis/deepseek/ask.test.js +13 -13
- package/clis/deepseek/detail.js +38 -0
- package/clis/deepseek/detail.test.js +81 -0
- package/clis/deepseek/history.js +1 -0
- package/clis/deepseek/new.js +1 -0
- package/clis/deepseek/read.js +1 -0
- package/clis/deepseek/send.js +140 -0
- package/clis/deepseek/send.test.js +107 -0
- package/clis/deepseek/status.js +1 -0
- package/clis/deepseek/utils.js +66 -0
- package/clis/deepseek/utils.test.js +107 -1
- package/clis/defillama/defillama.test.js +99 -0
- package/clis/defillama/protocol.js +84 -0
- package/clis/defillama/protocols.js +55 -0
- package/clis/defillama/utils.js +99 -0
- package/clis/devto/latest.js +74 -0
- package/clis/dockerhub/image.js +52 -0
- package/clis/dockerhub/search.js +47 -0
- package/clis/dockerhub/utils.js +100 -0
- package/clis/doubao/ask.js +7 -3
- package/clis/doubao/detail.js +1 -0
- package/clis/doubao/history.js +1 -0
- package/clis/doubao/meeting-summary.js +1 -0
- package/clis/doubao/meeting-transcript.js +1 -0
- package/clis/doubao/new.js +1 -0
- package/clis/doubao/read.js +1 -0
- package/clis/doubao/send.js +1 -0
- package/clis/doubao/status.js +1 -0
- package/clis/douyin/draft.test.js +1 -30
- package/clis/endoflife/endoflife.test.js +51 -0
- package/clis/endoflife/product.js +55 -0
- package/clis/endoflife/utils.js +89 -0
- package/clis/facebook/__fixtures__/notifications-page.html +13 -0
- package/clis/facebook/notifications.js +326 -30
- package/clis/facebook/notifications.test.js +458 -0
- package/clis/flathub/app.js +71 -0
- package/clis/flathub/flathub.test.js +90 -0
- package/clis/flathub/search.js +80 -0
- package/clis/flathub/utils.js +114 -0
- package/clis/gemini/ask.js +7 -3
- package/clis/gemini/ask.test.js +2 -2
- package/clis/gemini/deep-research-result.js +6 -2
- package/clis/gemini/deep-research-result.test.js +15 -14
- package/clis/gemini/deep-research.js +8 -4
- package/clis/gemini/deep-research.test.js +15 -18
- package/clis/gemini/image.js +7 -2
- package/clis/gemini/new.js +1 -0
- package/clis/gemini/utils.js +0 -4
- package/clis/google-scholar/cite.js +0 -1
- package/clis/google-scholar/profile.js +0 -1
- package/clis/google-scholar/search.js +0 -1
- package/clis/goproxy/goproxy.test.js +103 -0
- package/clis/goproxy/module.js +47 -0
- package/clis/goproxy/utils.js +165 -0
- package/clis/goproxy/versions.js +59 -0
- package/clis/gov-law/recent.js +0 -1
- package/clis/gov-law/search.js +0 -1
- package/clis/gov-policy/__fixtures__/recent.html +16 -0
- package/clis/gov-policy/__fixtures__/search.html +41 -0
- package/clis/gov-policy/gov-policy.test.js +224 -0
- package/clis/gov-policy/recent.js +66 -24
- package/clis/gov-policy/search.js +65 -23
- package/clis/gov-policy/utils.js +54 -0
- package/clis/grok/ask.js +49 -265
- package/clis/grok/ask.test.js +21 -46
- package/clis/grok/detail.js +60 -0
- package/clis/grok/history.js +48 -0
- package/clis/grok/{image.ts → image.js} +56 -70
- package/clis/grok/image.test.ts +20 -0
- package/clis/grok/new.js +20 -0
- package/clis/grok/read.js +39 -0
- package/clis/grok/send.js +50 -0
- package/clis/grok/status.js +41 -0
- package/clis/grok/utils.js +326 -0
- package/clis/grok/utils.test.js +103 -0
- package/clis/hf/datasets.js +88 -0
- package/clis/hf/hf.test.js +16 -0
- package/clis/hf/models.js +91 -0
- package/clis/hf/paper.js +79 -0
- package/clis/hf/spaces.js +101 -0
- package/clis/hf/top.js +1 -0
- package/clis/homebrew/cask.js +39 -0
- package/clis/homebrew/formula.js +41 -0
- package/clis/homebrew/popular.js +54 -0
- package/clis/homebrew/utils.js +100 -0
- package/clis/hupu/__fixtures__/hot-home.html +64 -0
- package/clis/hupu/detail.js +0 -1
- package/clis/hupu/hot.js +156 -35
- package/clis/hupu/hot.test.js +224 -0
- package/clis/hupu/search.js +0 -1
- package/clis/instagram/note.js +1 -1
- package/clis/instagram/note.test.js +1 -29
- package/clis/instagram/post.js +1 -1
- package/clis/instagram/post.test.js +1 -1
- package/clis/instagram/reel.js +1 -1
- package/clis/instagram/story.js +1 -1
- package/clis/instagram/story.test.js +1 -34
- package/clis/jd/commands.test.js +1 -24
- package/clis/lichess/lichess.test.js +85 -0
- package/clis/lichess/top.js +46 -0
- package/clis/lichess/user.js +91 -0
- package/clis/lichess/utils.js +97 -0
- package/clis/linkedin/search.js +107 -10
- package/clis/linkedin/search.test.js +222 -0
- package/clis/linux-do/feed.js +2 -5
- package/clis/linux-do/feed.test.js +35 -0
- package/clis/lobsters/domain.js +92 -0
- package/clis/maven/artifact.js +49 -0
- package/clis/maven/search.js +51 -0
- package/clis/maven/utils.js +110 -0
- package/clis/mdn/search.js +97 -0
- package/clis/medium/tag.js +135 -0
- package/clis/npm/downloads.js +59 -0
- package/clis/npm/package.js +70 -0
- package/clis/npm/search.js +49 -0
- package/clis/npm/utils.js +76 -0
- package/clis/nuget/nuget.test.js +111 -0
- package/clis/nuget/package.js +101 -0
- package/clis/nuget/search.js +69 -0
- package/clis/nuget/utils.js +87 -0
- package/clis/nvd/cve.js +121 -0
- package/clis/oeis/oeis.test.js +88 -0
- package/clis/oeis/search.js +63 -0
- package/clis/oeis/sequence.js +71 -0
- package/clis/oeis/utils.js +88 -0
- package/clis/openalex/search.js +69 -0
- package/clis/openalex/utils.js +160 -0
- package/clis/openalex/work.js +65 -0
- package/clis/openfda/drug-label.js +74 -0
- package/clis/openfda/food-recall.js +65 -0
- package/clis/openfda/openfda.test.js +114 -0
- package/clis/openfda/utils.js +67 -0
- package/clis/osv/osv.test.js +97 -0
- package/clis/osv/query.js +72 -0
- package/clis/osv/utils.js +169 -0
- package/clis/osv/vulnerability.js +54 -0
- package/clis/packagist/package.js +49 -0
- package/clis/packagist/search.js +43 -0
- package/clis/packagist/utils.js +113 -0
- package/clis/paperreview/feedback.js +1 -1
- package/clis/paperreview/review.js +1 -1
- package/clis/paperreview/submit.js +1 -1
- package/clis/pixiv/download.test.js +1 -1
- package/clis/pixiv/illusts.test.js +1 -1
- package/clis/pixiv/search.test.js +1 -1
- package/clis/pubmed/article.js +50 -0
- package/clis/pubmed/author.js +64 -0
- package/clis/pubmed/citations.js +36 -0
- package/clis/pubmed/pubmed.test.js +276 -0
- package/clis/pubmed/related.js +45 -0
- package/clis/pubmed/search.js +75 -0
- package/clis/pubmed/utils.js +309 -0
- package/clis/pypi/downloads.js +66 -0
- package/clis/pypi/package.js +79 -0
- package/clis/pypi/utils.js +55 -0
- package/clis/quark/mv.js +1 -1
- package/clis/quark/save.js +1 -1
- package/clis/qwen/ask.js +85 -0
- package/clis/qwen/detail.js +62 -0
- package/clis/qwen/history.js +61 -0
- package/clis/qwen/image.js +179 -0
- package/clis/qwen/new.js +23 -0
- package/clis/qwen/read.js +41 -0
- package/clis/qwen/send.js +55 -0
- package/clis/qwen/status.js +37 -0
- package/clis/qwen/utils.js +409 -0
- package/clis/qwen/utils.test.js +45 -0
- package/clis/rest-countries/country.js +65 -0
- package/clis/rest-countries/region.js +64 -0
- package/clis/rest-countries/rest-countries.test.js +83 -0
- package/clis/rest-countries/utils.js +126 -0
- package/clis/reuters/article-detail.js +53 -0
- package/clis/reuters/reuters.test.js +299 -0
- package/clis/reuters/search.js +45 -34
- package/clis/reuters/utils.js +159 -0
- package/clis/rfc/rfc.js +52 -0
- package/clis/rfc/rfc.test.js +74 -0
- package/clis/rfc/utils.js +72 -0
- package/clis/rubygems/gem.js +42 -0
- package/clis/rubygems/search.js +47 -0
- package/clis/rubygems/utils.js +86 -0
- package/clis/stackoverflow/related.js +66 -0
- package/clis/stackoverflow/stackoverflow.test.js +58 -0
- package/clis/stackoverflow/tag.js +60 -0
- package/clis/stackoverflow/user.js +50 -0
- package/clis/stackoverflow/utils.js +118 -0
- package/clis/steam/app.js +67 -0
- package/clis/steam/search.js +58 -0
- package/clis/steam/steam.test.js +46 -0
- package/clis/steam/utils.js +107 -0
- package/clis/taobao/commands.test.js +1 -24
- package/clis/test-utils.js +61 -0
- package/clis/tieba/hot.js +0 -1
- package/clis/tiktok/comment.js +128 -41
- package/clis/tiktok/creator-videos.js +270 -0
- package/clis/tiktok/creator-videos.test.js +113 -0
- package/clis/tiktok/explore.js +137 -29
- package/clis/tiktok/follow.js +115 -33
- package/clis/tiktok/following.js +157 -36
- package/clis/tiktok/friends.js +139 -37
- package/clis/tiktok/live.js +137 -41
- package/clis/tiktok/notifications.js +141 -38
- package/clis/tiktok/refactor.test.js +389 -0
- package/clis/tiktok/unfollow.js +124 -38
- package/clis/tiktok/user.js +203 -29
- package/clis/tiktok/utils.js +505 -0
- package/clis/tiktok/write-refactor.test.js +370 -0
- package/clis/toutiao/articles.js +36 -62
- package/clis/toutiao/hot.js +63 -0
- package/clis/toutiao/toutiao.test.js +378 -0
- package/clis/toutiao/utils.js +161 -0
- package/clis/tvmaze/search.js +61 -0
- package/clis/tvmaze/show.js +60 -0
- package/clis/tvmaze/tvmaze.test.js +93 -0
- package/clis/tvmaze/utils.js +110 -0
- package/clis/twitter/accept.js +1 -1
- package/clis/twitter/followers.js +134 -69
- package/clis/twitter/reply-dm.js +1 -1
- package/clis/twitter/reply.test.js +1 -29
- package/clis/uisdc/news.js +105 -0
- package/clis/uisdc/news.test.js +66 -0
- package/clis/wanfang/search.js +0 -1
- package/clis/web/read.js +47 -17
- package/clis/web/read.test.js +101 -1
- package/clis/weixin/create-draft.js +1 -1
- package/clis/weixin/drafts.js +1 -1
- package/clis/weixin/drafts.test.js +5 -1
- package/clis/weixin/search.js +157 -0
- package/clis/weixin/search.test.js +227 -0
- package/clis/wikidata/entity.js +60 -0
- package/clis/wikidata/search.js +50 -0
- package/clis/wikidata/utils.js +117 -0
- package/clis/wikidata/wikidata.test.js +83 -0
- package/clis/wikipedia/page.js +95 -0
- package/clis/wttr/current.js +63 -0
- package/clis/wttr/forecast.js +71 -0
- package/clis/wttr/utils.js +50 -0
- package/clis/wttr/wttr.test.js +84 -0
- package/clis/xianyu/chat.js +16 -4
- package/clis/xianyu/chat.test.js +64 -0
- package/clis/xianyu/publish.js +485 -0
- package/clis/xianyu/publish.test.js +220 -0
- package/clis/xiaoe/catalog.js +105 -40
- package/clis/xiaoe/content.js +164 -29
- package/clis/xiaoe/courses.js +86 -29
- package/clis/xiaoe/xiaoe.test.js +486 -0
- package/clis/xiaohongshu/creator-notes-summary.js +1 -1
- package/clis/xiaohongshu/publish.js +16 -3
- package/clis/xiaohongshu/publish.test.js +46 -1
- package/clis/youtube/transcript.js +13 -19
- package/clis/youtube/transcript.test.js +17 -0
- package/clis/yuanbao/ask.js +17 -66
- package/clis/yuanbao/ask.test.js +5 -5
- package/clis/yuanbao/detail.js +65 -0
- package/clis/yuanbao/history.js +51 -0
- package/clis/yuanbao/new.js +1 -0
- package/clis/yuanbao/read.js +38 -0
- package/clis/yuanbao/send.js +57 -0
- package/clis/yuanbao/shared.js +297 -5
- package/clis/yuanbao/shared.test.js +80 -0
- package/clis/yuanbao/status.js +44 -0
- package/clis/zlibrary/commands.test.js +1 -11
- package/dist/src/browser/base-page.d.ts +9 -0
- package/dist/src/browser/base-page.js +44 -1
- package/dist/src/browser/base-page.test.js +66 -0
- package/dist/src/browser/cdp.d.ts +1 -0
- package/dist/src/browser/cdp.js +51 -9
- package/dist/src/browser/daemon-client.d.ts +4 -0
- package/dist/src/browser/errors.js +1 -1
- package/dist/src/browser/page.d.ts +1 -1
- package/dist/src/browser/page.js +3 -1
- package/dist/src/browser/page.test.js +29 -0
- package/dist/src/browser/target-errors.d.ts +2 -1
- package/dist/src/browser/target-errors.js +1 -0
- package/dist/src/browser/target-resolver.d.ts +25 -0
- package/dist/src/browser/target-resolver.js +43 -0
- package/dist/src/build-manifest.js +9 -4
- package/dist/src/build-manifest.test.js +2 -8
- package/dist/src/capabilityRouting.d.ts +16 -1
- package/dist/src/capabilityRouting.js +24 -1
- package/dist/src/capabilityRouting.test.js +19 -1
- package/dist/src/cli.js +76 -11
- package/dist/src/cli.test.js +150 -0
- package/dist/src/commanderAdapter.js +0 -5
- package/dist/src/commanderAdapter.test.js +0 -1
- package/dist/src/discovery.js +2 -5
- package/dist/src/errors.js +1 -1
- package/dist/src/execution.d.ts +1 -1
- package/dist/src/execution.js +111 -27
- package/dist/src/execution.test.js +326 -17
- package/dist/src/help.d.ts +23 -2
- package/dist/src/help.js +41 -19
- package/dist/src/help.test.d.ts +1 -0
- package/dist/src/help.test.js +54 -0
- package/dist/src/main.js +14 -1
- package/dist/src/manifest-types.d.ts +5 -3
- package/dist/src/pipeline/executor.js +1 -1
- package/dist/src/pipeline/executor.test.js +8 -0
- package/dist/src/pipeline/registry.d.ts +9 -0
- package/dist/src/pipeline/registry.js +13 -1
- package/dist/src/pipeline/steps/browser.d.ts +1 -0
- package/dist/src/pipeline/steps/browser.js +10 -0
- package/dist/src/pipeline/steps/download.test.js +1 -0
- package/dist/src/registry-api.d.ts +1 -1
- package/dist/src/registry.d.ts +12 -11
- package/dist/src/registry.js +16 -6
- package/dist/src/registry.test.js +2 -2
- package/dist/src/runtime.d.ts +2 -1
- package/dist/src/runtime.js +1 -1
- package/dist/src/serialization.d.ts +2 -2
- package/dist/src/serialization.js +4 -6
- package/dist/src/serialization.test.js +17 -0
- package/dist/src/types.d.ts +17 -0
- package/dist/src/validate.js +15 -11
- package/dist/src/validate.test.d.ts +9 -0
- package/dist/src/validate.test.js +90 -0
- package/package.json +1 -1
- package/scripts/fetch-adapters.js +1 -1
- package/scripts/typed-error-lint-baseline.json +5 -77
- package/clis/ctrip/search.test.js +0 -64
- package/clis/gov-policy/commands.test.js +0 -27
- package/clis/linux-do/category.js +0 -37
- package/clis/linux-do/hot.js +0 -26
- package/clis/linux-do/latest.js +0 -19
- package/clis/pixiv/test-utils.js +0 -23
- package/clis/toutiao/articles.test.js +0 -30
- package/dist/src/analysis.d.ts +0 -40
- package/dist/src/analysis.js +0 -172
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { getRegistry } from '@jackwener/opencli/registry';
|
|
3
|
+
import { ArgumentError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
|
|
4
|
+
import './user.js';
|
|
5
|
+
import './top.js';
|
|
6
|
+
|
|
7
|
+
afterEach(() => {
|
|
8
|
+
vi.unstubAllGlobals();
|
|
9
|
+
vi.restoreAllMocks();
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
describe('lichess user adapter', () => {
|
|
13
|
+
const cmd = getRegistry().get('lichess/user');
|
|
14
|
+
|
|
15
|
+
it('rejects bad usernames before fetching', async () => {
|
|
16
|
+
const fetchMock = vi.fn();
|
|
17
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
18
|
+
await expect(cmd.func({ username: '' })).rejects.toThrow(ArgumentError);
|
|
19
|
+
await expect(cmd.func({ username: 'a' })).rejects.toThrow(ArgumentError); // too short
|
|
20
|
+
await expect(cmd.func({ username: 'has space' })).rejects.toThrow(ArgumentError);
|
|
21
|
+
expect(fetchMock).not.toHaveBeenCalled();
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('maps HTTP 429 to CommandExecutionError', async () => {
|
|
25
|
+
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response('rate limited', { status: 429 })));
|
|
26
|
+
await expect(cmd.func({ username: 'somebody' })).rejects.toThrow(CommandExecutionError);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('treats disabled accounts as EmptyResultError (not row of nulls)', async () => {
|
|
30
|
+
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(JSON.stringify({
|
|
31
|
+
id: 'closed-user', username: 'ClosedUser', disabled: true,
|
|
32
|
+
}), { status: 200 })));
|
|
33
|
+
await expect(cmd.func({ username: 'ClosedUser' })).rejects.toThrow(EmptyResultError);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('picks the most-played perf as topPerf', async () => {
|
|
37
|
+
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(JSON.stringify({
|
|
38
|
+
id: 'someplayer',
|
|
39
|
+
username: 'SomePlayer',
|
|
40
|
+
createdAt: 1543000000000,
|
|
41
|
+
seenAt: 1700000000000,
|
|
42
|
+
count: { all: 100, win: 50, loss: 30, draw: 20 },
|
|
43
|
+
perfs: {
|
|
44
|
+
bullet: { games: 9000, rating: 2700 },
|
|
45
|
+
blitz: { games: 100, rating: 2200 },
|
|
46
|
+
puzzle: { games: 99999, rating: 2900 }, // ignored — not playable
|
|
47
|
+
},
|
|
48
|
+
}), { status: 200 })));
|
|
49
|
+
const rows = await cmd.func({ username: 'SomePlayer' });
|
|
50
|
+
expect(rows[0]).toMatchObject({
|
|
51
|
+
username: 'SomePlayer', id: 'someplayer',
|
|
52
|
+
gamesAll: 100, topPerfName: 'bullet', topPerfRating: 2700, topPerfGames: 9000,
|
|
53
|
+
url: 'https://lichess.org/@/SomePlayer',
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
describe('lichess top adapter', () => {
|
|
59
|
+
const cmd = getRegistry().get('lichess/top');
|
|
60
|
+
|
|
61
|
+
it('rejects unknown perf types before fetching', async () => {
|
|
62
|
+
const fetchMock = vi.fn();
|
|
63
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
64
|
+
await expect(cmd.func({ perf: '' })).rejects.toThrow(ArgumentError);
|
|
65
|
+
await expect(cmd.func({ perf: 'turbo' })).rejects.toThrow(ArgumentError);
|
|
66
|
+
await expect(cmd.func({ perf: 'blitz', limit: 9999 })).rejects.toThrow(ArgumentError);
|
|
67
|
+
expect(fetchMock).not.toHaveBeenCalled();
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('throws EmptyResultError on empty leaderboard', async () => {
|
|
71
|
+
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(JSON.stringify({ users: [] }), { status: 200 })));
|
|
72
|
+
await expect(cmd.func({ perf: 'blitz', limit: 5 })).rejects.toThrow(EmptyResultError);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('round-trips username from leaderboard into perf-specific URL', async () => {
|
|
76
|
+
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(JSON.stringify({
|
|
77
|
+
users: [{ id: 'magnus', username: 'Magnus', title: 'GM', perfs: { blitz: { rating: 3001, progress: 7 } } }],
|
|
78
|
+
}), { status: 200 })));
|
|
79
|
+
const rows = await cmd.func({ perf: 'blitz', limit: 3 });
|
|
80
|
+
expect(rows[0]).toMatchObject({
|
|
81
|
+
rank: 1, username: 'Magnus', title: 'GM', rating: 3001, progress: 7,
|
|
82
|
+
url: 'https://lichess.org/@/Magnus/perf/blitz',
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
});
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// lichess top — top-N leaderboard for a given perf type.
|
|
2
|
+
//
|
|
3
|
+
// Hits `/api/player/top/<n>/<perf>`. Returns the leaderboard rows; usernames
|
|
4
|
+
// round-trip into `lichess user` for full profile detail.
|
|
5
|
+
import { cli, Strategy } from '@jackwener/opencli/registry';
|
|
6
|
+
import { EmptyResultError } from '@jackwener/opencli/errors';
|
|
7
|
+
import { LICHESS_BASE, lichessFetch, requireBoundedInt, requirePerf } from './utils.js';
|
|
8
|
+
|
|
9
|
+
cli({
|
|
10
|
+
site: 'lichess',
|
|
11
|
+
name: 'top',
|
|
12
|
+
access: 'read',
|
|
13
|
+
description: 'Top-N Lichess leaderboard for a perf type (bullet/blitz/rapid/classical/...)',
|
|
14
|
+
domain: 'lichess.org',
|
|
15
|
+
strategy: Strategy.PUBLIC,
|
|
16
|
+
browser: false,
|
|
17
|
+
args: [
|
|
18
|
+
{ name: 'perf', positional: true, required: true, help: 'Perf type (bullet, blitz, rapid, classical, ultraBullet, chess960, ...)' },
|
|
19
|
+
{ name: 'limit', type: 'int', default: 10, help: 'Top-N rows (1-200)' },
|
|
20
|
+
],
|
|
21
|
+
columns: ['rank', 'username', 'id', 'title', 'rating', 'progress', 'patron', 'url'],
|
|
22
|
+
func: async (args) => {
|
|
23
|
+
const perf = requirePerf(args.perf);
|
|
24
|
+
const limit = requireBoundedInt(args.limit, 10, 200);
|
|
25
|
+
const url = `${LICHESS_BASE}/api/player/top/${limit}/${encodeURIComponent(perf)}`;
|
|
26
|
+
const body = await lichessFetch(url, 'lichess top');
|
|
27
|
+
const list = Array.isArray(body?.users) ? body.users : [];
|
|
28
|
+
if (!list.length) {
|
|
29
|
+
throw new EmptyResultError('lichess top', `Lichess returned no leaderboard rows for perf "${perf}".`);
|
|
30
|
+
}
|
|
31
|
+
return list.slice(0, limit).map((u, i) => {
|
|
32
|
+
const username = typeof u?.username === 'string' ? u.username : '';
|
|
33
|
+
const perfBlock = u?.perfs && typeof u.perfs === 'object' ? u.perfs[perf] ?? {} : {};
|
|
34
|
+
return {
|
|
35
|
+
rank: i + 1,
|
|
36
|
+
username,
|
|
37
|
+
id: typeof u?.id === 'string' ? u.id : null,
|
|
38
|
+
title: typeof u?.title === 'string' ? u.title : null,
|
|
39
|
+
rating: typeof perfBlock.rating === 'number' ? perfBlock.rating : null,
|
|
40
|
+
progress: typeof perfBlock.progress === 'number' ? perfBlock.progress : null,
|
|
41
|
+
patron: u?.patron === true,
|
|
42
|
+
url: username ? `${LICHESS_BASE}/@/${encodeURIComponent(username)}/perf/${encodeURIComponent(perf)}` : '',
|
|
43
|
+
};
|
|
44
|
+
});
|
|
45
|
+
},
|
|
46
|
+
});
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
// lichess user — fetch a public Lichess player profile.
|
|
2
|
+
//
|
|
3
|
+
// Hits `/api/user/<username>`. Returns the agent-useful slice: handle, title,
|
|
4
|
+
// flags (online / patron), counts, top-rated perf, profile bio.
|
|
5
|
+
import { cli, Strategy } from '@jackwener/opencli/registry';
|
|
6
|
+
import { EmptyResultError } from '@jackwener/opencli/errors';
|
|
7
|
+
import { LICHESS_BASE, formatTimestamp, lichessFetch, requireUsername } from './utils.js';
|
|
8
|
+
|
|
9
|
+
cli({
|
|
10
|
+
site: 'lichess',
|
|
11
|
+
name: 'user',
|
|
12
|
+
access: 'read',
|
|
13
|
+
description: 'Fetch a Lichess player profile by username (rating, perfs, counts)',
|
|
14
|
+
domain: 'lichess.org',
|
|
15
|
+
strategy: Strategy.PUBLIC,
|
|
16
|
+
browser: false,
|
|
17
|
+
args: [
|
|
18
|
+
{ name: 'username', positional: true, required: true, help: 'Lichess username (case-insensitive)' },
|
|
19
|
+
],
|
|
20
|
+
columns: [
|
|
21
|
+
'username',
|
|
22
|
+
'id',
|
|
23
|
+
'title',
|
|
24
|
+
'patron',
|
|
25
|
+
'online',
|
|
26
|
+
'tosViolation',
|
|
27
|
+
'createdAt',
|
|
28
|
+
'seenAt',
|
|
29
|
+
'gamesAll',
|
|
30
|
+
'gamesWin',
|
|
31
|
+
'gamesLoss',
|
|
32
|
+
'gamesDraw',
|
|
33
|
+
'topPerfName',
|
|
34
|
+
'topPerfRating',
|
|
35
|
+
'topPerfGames',
|
|
36
|
+
'fideRating',
|
|
37
|
+
'country',
|
|
38
|
+
'bio',
|
|
39
|
+
'url',
|
|
40
|
+
],
|
|
41
|
+
func: async (args) => {
|
|
42
|
+
const username = requireUsername(args.username);
|
|
43
|
+
const url = `${LICHESS_BASE}/api/user/${encodeURIComponent(username)}`;
|
|
44
|
+
const body = await lichessFetch(url, 'lichess user');
|
|
45
|
+
if (!body || typeof body !== 'object') {
|
|
46
|
+
throw new EmptyResultError('lichess user', `Lichess user "${username}" returned empty payload.`);
|
|
47
|
+
}
|
|
48
|
+
// Lichess marks closed accounts with `disabled: true` and strips data.
|
|
49
|
+
// Surface as EmptyResultError instead of a row of nulls (silent-fallback).
|
|
50
|
+
if (body.disabled === true) {
|
|
51
|
+
throw new EmptyResultError('lichess user', `Lichess user "${username}" is closed/disabled.`);
|
|
52
|
+
}
|
|
53
|
+
const perfs = body.perfs && typeof body.perfs === 'object' ? body.perfs : {};
|
|
54
|
+
// Pick the perf with the most games (excluding puzzle/storm/racer ephemera).
|
|
55
|
+
const playablePerfs = Object.entries(perfs).filter(([k, v]) => v && typeof v === 'object' && !['puzzle', 'storm', 'racer', 'streak'].includes(k));
|
|
56
|
+
let topPerfName = null;
|
|
57
|
+
let topPerfRating = null;
|
|
58
|
+
let topPerfGames = null;
|
|
59
|
+
for (const [name, p] of playablePerfs) {
|
|
60
|
+
const games = typeof p.games === 'number' ? p.games : 0;
|
|
61
|
+
if (topPerfGames == null || games > topPerfGames) {
|
|
62
|
+
topPerfName = name;
|
|
63
|
+
topPerfGames = games;
|
|
64
|
+
topPerfRating = typeof p.rating === 'number' ? p.rating : null;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
const counts = body.count && typeof body.count === 'object' ? body.count : {};
|
|
68
|
+
const profile = body.profile && typeof body.profile === 'object' ? body.profile : {};
|
|
69
|
+
return [{
|
|
70
|
+
username: typeof body.username === 'string' ? body.username : username,
|
|
71
|
+
id: typeof body.id === 'string' ? body.id : null,
|
|
72
|
+
title: typeof body.title === 'string' ? body.title : null,
|
|
73
|
+
patron: body.patron === true,
|
|
74
|
+
online: body.online === true,
|
|
75
|
+
tosViolation: body.tosViolation === true,
|
|
76
|
+
createdAt: formatTimestamp(body.createdAt),
|
|
77
|
+
seenAt: formatTimestamp(body.seenAt),
|
|
78
|
+
gamesAll: typeof counts.all === 'number' ? counts.all : null,
|
|
79
|
+
gamesWin: typeof counts.win === 'number' ? counts.win : null,
|
|
80
|
+
gamesLoss: typeof counts.loss === 'number' ? counts.loss : null,
|
|
81
|
+
gamesDraw: typeof counts.draw === 'number' ? counts.draw : null,
|
|
82
|
+
topPerfName,
|
|
83
|
+
topPerfRating,
|
|
84
|
+
topPerfGames,
|
|
85
|
+
fideRating: typeof profile.fideRating === 'number' ? profile.fideRating : null,
|
|
86
|
+
country: typeof profile.country === 'string' ? profile.country : null,
|
|
87
|
+
bio: typeof profile.bio === 'string' ? profile.bio.trim() : null,
|
|
88
|
+
url: `${LICHESS_BASE}/@/${encodeURIComponent(typeof body.username === 'string' ? body.username : username)}`,
|
|
89
|
+
}];
|
|
90
|
+
},
|
|
91
|
+
});
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
// Shared helpers for the lichess.org public REST adapters.
|
|
2
|
+
//
|
|
3
|
+
// Lichess exposes a generous unauthenticated API at `lichess.org/api`. We keep
|
|
4
|
+
// the surface narrow: `user` (profile) + `top` (per-perf top-N leaderboard).
|
|
5
|
+
// No API key required; rate limit is 60 req/min per IP.
|
|
6
|
+
import { ArgumentError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
|
|
7
|
+
|
|
8
|
+
export const LICHESS_BASE = 'https://lichess.org';
|
|
9
|
+
const UA = 'opencli-lichess-adapter/1.0 (+https://github.com/jackwener/opencli; mailto:opencli@example.com)';
|
|
10
|
+
|
|
11
|
+
// Lichess usernames are 2-30 chars: letters, digits, underscore, dash. Case-insensitive.
|
|
12
|
+
const USERNAME_PATTERN = /^[A-Za-z0-9_-]{2,30}$/;
|
|
13
|
+
|
|
14
|
+
// `perfType` values lichess accepts for the `/api/player/top/<n>/<perf>` endpoint.
|
|
15
|
+
// Source: lichess-org/api docs.
|
|
16
|
+
export const LICHESS_PERFS = new Set([
|
|
17
|
+
'ultraBullet', 'bullet', 'blitz', 'rapid', 'classical',
|
|
18
|
+
'chess960', 'crazyhouse', 'antichess', 'atomic', 'horde',
|
|
19
|
+
'kingOfTheHill', 'racingKings', 'threeCheck',
|
|
20
|
+
]);
|
|
21
|
+
|
|
22
|
+
export function requireUsername(value) {
|
|
23
|
+
const raw = String(value ?? '').trim();
|
|
24
|
+
if (!raw) throw new ArgumentError('lichess username is required');
|
|
25
|
+
if (!USERNAME_PATTERN.test(raw)) {
|
|
26
|
+
throw new ArgumentError(
|
|
27
|
+
`lichess username "${value}" is not a valid handle`,
|
|
28
|
+
'Allowed: letters, digits, underscore, dash; length 2-30.',
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
return raw;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function requirePerf(value) {
|
|
35
|
+
const raw = String(value ?? '').trim();
|
|
36
|
+
if (!raw) throw new ArgumentError('lichess perf is required (e.g. "blitz", "bullet", "rapid")');
|
|
37
|
+
if (!LICHESS_PERFS.has(raw)) {
|
|
38
|
+
throw new ArgumentError(
|
|
39
|
+
`lichess perf "${value}" is not recognised`,
|
|
40
|
+
`Allowed values: ${[...LICHESS_PERFS].join(', ')}.`,
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
return raw;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function requireBoundedInt(value, defaultValue, maxValue, label = 'limit') {
|
|
47
|
+
const raw = value ?? defaultValue;
|
|
48
|
+
const n = typeof raw === 'number' ? raw : Number(raw);
|
|
49
|
+
if (!Number.isInteger(n) || n <= 0) {
|
|
50
|
+
throw new ArgumentError(`lichess ${label} must be a positive integer`);
|
|
51
|
+
}
|
|
52
|
+
if (n > maxValue) {
|
|
53
|
+
throw new ArgumentError(`lichess ${label} must be <= ${maxValue}`);
|
|
54
|
+
}
|
|
55
|
+
return n;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export async function lichessFetch(url, label) {
|
|
59
|
+
let resp;
|
|
60
|
+
try {
|
|
61
|
+
resp = await fetch(url, { headers: { 'user-agent': UA, accept: 'application/json' } });
|
|
62
|
+
}
|
|
63
|
+
catch (err) {
|
|
64
|
+
throw new CommandExecutionError(
|
|
65
|
+
`${label} request failed: ${err?.message ?? err}`,
|
|
66
|
+
'Check that lichess.org is reachable from this network.',
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
if (resp.status === 404) {
|
|
70
|
+
throw new EmptyResultError(label, `Lichess returned 404 for ${url}.`);
|
|
71
|
+
}
|
|
72
|
+
if (resp.status === 429) {
|
|
73
|
+
throw new CommandExecutionError(
|
|
74
|
+
`${label} returned HTTP 429 (rate limited)`,
|
|
75
|
+
'Lichess throttles anonymous traffic at ~60 req/min; back off and retry.',
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
if (!resp.ok) {
|
|
79
|
+
throw new CommandExecutionError(`${label} returned HTTP ${resp.status}`);
|
|
80
|
+
}
|
|
81
|
+
let body;
|
|
82
|
+
try {
|
|
83
|
+
body = await resp.json();
|
|
84
|
+
}
|
|
85
|
+
catch (err) {
|
|
86
|
+
throw new CommandExecutionError(`${label} returned malformed JSON: ${err?.message ?? err}`);
|
|
87
|
+
}
|
|
88
|
+
return body;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Format a lichess unix-ms timestamp as ISO date (YYYY-MM-DD). `null` when missing. */
|
|
92
|
+
export function formatTimestamp(ms) {
|
|
93
|
+
if (typeof ms !== 'number' || !Number.isFinite(ms) || ms <= 0) return null;
|
|
94
|
+
const d = new Date(ms);
|
|
95
|
+
if (Number.isNaN(d.getTime())) return null;
|
|
96
|
+
return d.toISOString();
|
|
97
|
+
}
|
package/clis/linkedin/search.js
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
import { cli, Strategy } from '@jackwener/opencli/registry';
|
|
2
|
-
import { ArgumentError, CommandExecutionError } from '@jackwener/opencli/errors';
|
|
2
|
+
import { ArgumentError, AuthRequiredError, CommandExecutionError } from '@jackwener/opencli/errors';
|
|
3
|
+
const LINKEDIN_DOMAIN = 'linkedin.com';
|
|
4
|
+
const MIN_LIMIT = 1;
|
|
5
|
+
const MAX_LIMIT = 100;
|
|
6
|
+
const MIN_START = 0;
|
|
3
7
|
// ── Filter value mappings ──────────────────────────────────────────────
|
|
4
8
|
const EXPERIENCE_LEVELS = {
|
|
5
9
|
internship: '1',
|
|
@@ -66,6 +70,19 @@ function mapFilterValues(input, mapping, label) {
|
|
|
66
70
|
function normalizeWhitespace(value) {
|
|
67
71
|
return String(value ?? '').replace(/\s+/g, ' ').trim();
|
|
68
72
|
}
|
|
73
|
+
function parseIntegerArg(value, label, fallback, min, max = Infinity) {
|
|
74
|
+
if (value === undefined || value === null || value === '')
|
|
75
|
+
return fallback;
|
|
76
|
+
const parsed = Number(value);
|
|
77
|
+
if (!Number.isFinite(parsed) || !Number.isInteger(parsed)) {
|
|
78
|
+
throw new ArgumentError(`${label} must be an integer, got ${JSON.stringify(value)}`);
|
|
79
|
+
}
|
|
80
|
+
if (parsed < min || parsed > max) {
|
|
81
|
+
const range = Number.isFinite(max) ? `between ${min} and ${max}` : `at least ${min}`;
|
|
82
|
+
throw new ArgumentError(`${label} must be ${range}, got ${parsed}`);
|
|
83
|
+
}
|
|
84
|
+
return parsed;
|
|
85
|
+
}
|
|
69
86
|
function decodeLinkedinRedirect(url) {
|
|
70
87
|
if (!url)
|
|
71
88
|
return '';
|
|
@@ -120,6 +137,31 @@ function buildVoyagerUrl(input, offset, count) {
|
|
|
120
137
|
.replace(/%29/gi, ')');
|
|
121
138
|
return '/voyager/api/voyagerJobsDashJobCards?' + params.toString() + '&query=' + query + '&start=' + offset;
|
|
122
139
|
}
|
|
140
|
+
function looksLinkedInAuthWallText(value) {
|
|
141
|
+
const text = String(value ?? '').replace(/\s+/g, ' ').trim().toLowerCase();
|
|
142
|
+
if (!text)
|
|
143
|
+
return false;
|
|
144
|
+
return /\b(sign in|log in|join linkedin)\b/.test(text) ||
|
|
145
|
+
/linkedin\.com\/(login|checkpoint|authwall)/i.test(text) ||
|
|
146
|
+
/\b(captcha|verification required)\b/.test(text) ||
|
|
147
|
+
/(请登录|登录领英|安全验证)/.test(text);
|
|
148
|
+
}
|
|
149
|
+
function buildLinkedInAuthProbeScript() {
|
|
150
|
+
return `(() => {
|
|
151
|
+
const text = [
|
|
152
|
+
window.location.href || '',
|
|
153
|
+
document.title || '',
|
|
154
|
+
document.body ? (document.body.innerText || '').slice(0, 4000) : '',
|
|
155
|
+
].join('\\n');
|
|
156
|
+
return ${looksLinkedInAuthWallText.toString()}(text);
|
|
157
|
+
})()`;
|
|
158
|
+
}
|
|
159
|
+
async function assertLinkedInAuthenticated(page, context) {
|
|
160
|
+
const authRequired = await page.evaluate(buildLinkedInAuthProbeScript());
|
|
161
|
+
if (authRequired) {
|
|
162
|
+
throw new AuthRequiredError(LINKEDIN_DOMAIN, `${context} requires an active signed-in LinkedIn browser session`);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
123
165
|
// ── Company ID resolution (requires DOM interaction) ──────────────────
|
|
124
166
|
async function resolveCompanyIds(page, input) {
|
|
125
167
|
const rawValues = parseCsvArg(input);
|
|
@@ -209,13 +251,25 @@ async function fetchJobCards(page, input) {
|
|
|
209
251
|
const batch = await page.evaluate(`(async () => {
|
|
210
252
|
const jsession = document.cookie.split(';').map(p => p.trim())
|
|
211
253
|
.find(p => p.startsWith('JSESSIONID='))?.slice('JSESSIONID='.length);
|
|
212
|
-
if (!jsession)
|
|
254
|
+
if (!jsession) {
|
|
255
|
+
return {
|
|
256
|
+
authRequired: true,
|
|
257
|
+
error: 'LinkedIn JSESSIONID cookie not found. Please sign in to LinkedIn in the browser.'
|
|
258
|
+
};
|
|
259
|
+
}
|
|
213
260
|
|
|
214
261
|
const csrf = jsession.replace(/^"|"$/g, '');
|
|
215
262
|
const res = await fetch(${JSON.stringify(apiPath)}, {
|
|
216
263
|
credentials: 'include',
|
|
217
264
|
headers: { 'csrf-token': csrf, 'x-restli-protocol-version': '2.0.0' },
|
|
218
265
|
});
|
|
266
|
+
if (res.status === 401 || res.status === 403) {
|
|
267
|
+
const text = await res.text();
|
|
268
|
+
return {
|
|
269
|
+
authRequired: true,
|
|
270
|
+
error: 'LinkedIn API authentication failed: HTTP ' + res.status + ' ' + text.slice(0, 200)
|
|
271
|
+
};
|
|
272
|
+
}
|
|
219
273
|
if (!res.ok) {
|
|
220
274
|
const text = await res.text();
|
|
221
275
|
return { error: 'LinkedIn API error: HTTP ' + res.status + ' ' + text.slice(0, 200) };
|
|
@@ -223,6 +277,9 @@ async function fetchJobCards(page, input) {
|
|
|
223
277
|
return res.json();
|
|
224
278
|
})()`);
|
|
225
279
|
if (!batch || batch.error) {
|
|
280
|
+
if (batch?.authRequired) {
|
|
281
|
+
throw new AuthRequiredError(LINKEDIN_DOMAIN, batch.error);
|
|
282
|
+
}
|
|
226
283
|
throw new CommandExecutionError(batch?.error || 'LinkedIn search returned an unexpected response');
|
|
227
284
|
}
|
|
228
285
|
const elements = Array.isArray(batch?.elements) ? batch.elements : [];
|
|
@@ -259,17 +316,31 @@ async function fetchJobCards(page, input) {
|
|
|
259
316
|
}));
|
|
260
317
|
}
|
|
261
318
|
// ── Job detail enrichment (--details flag) ────────────────────────────
|
|
319
|
+
//
|
|
320
|
+
// Per-row failures should NOT abort the whole list (--details enriches N rows;
|
|
321
|
+
// partial failure is expected). But silent empty-string fields hide the failure
|
|
322
|
+
// from callers — previously `catch {}` and the `if (!job.url)` early-return
|
|
323
|
+
// both produced indistinguishable `description: '', apply_url: ''` payloads,
|
|
324
|
+
// so users could not tell "fetch failed" from "upstream had no description".
|
|
325
|
+
//
|
|
326
|
+
// The fix: surface `null` instead of `''` for missing/failed rows, set
|
|
327
|
+
// `detail_error` to a short reason ("no url" / "fetch failed: <message>" /
|
|
328
|
+
// "missing description"), and log every failure to stderr with the offending
|
|
329
|
+
// URL so debugging is possible. Successful rows have `detail_error: null`.
|
|
262
330
|
async function enrichJobDetails(page, jobs) {
|
|
263
331
|
const enriched = [];
|
|
264
332
|
for (let i = 0; i < jobs.length; i++) {
|
|
265
333
|
const job = jobs[i];
|
|
266
334
|
console.error(`[opencli:linkedin] Fetching details ${i + 1}/${jobs.length}: ${job.title}`);
|
|
267
335
|
if (!job.url) {
|
|
268
|
-
|
|
336
|
+
const reason = 'no url';
|
|
337
|
+
console.error(`[opencli:linkedin] Skipping detail for "${job.title}": ${reason}`);
|
|
338
|
+
enriched.push({ ...job, description: null, apply_url: null, detail_error: reason });
|
|
269
339
|
continue;
|
|
270
340
|
}
|
|
271
341
|
try {
|
|
272
342
|
await page.goto(job.url);
|
|
343
|
+
await assertLinkedInAuthenticated(page, 'LinkedIn job detail');
|
|
273
344
|
await page.wait({ text: 'About the job', timeout: 8 });
|
|
274
345
|
// Expand "Show more" button if present
|
|
275
346
|
await page.evaluate(`(() => {
|
|
@@ -301,14 +372,25 @@ async function enrichJobDetails(page, jobs) {
|
|
|
301
372
|
|
|
302
373
|
return { description, applyUrl: applyLink?.href || '' };
|
|
303
374
|
})()`);
|
|
375
|
+
const description = normalizeWhitespace(detail?.description);
|
|
376
|
+
const apply_url = decodeLinkedinRedirect(String(detail?.applyUrl ?? ''));
|
|
377
|
+
// Empty description after a successful fetch is itself a
|
|
378
|
+
// recognizable signal — surface it via detail_error instead of
|
|
379
|
+
// silently emitting an empty string.
|
|
380
|
+
const detail_error = description ? null : 'missing description';
|
|
304
381
|
enriched.push({
|
|
305
382
|
...job,
|
|
306
|
-
description:
|
|
307
|
-
apply_url:
|
|
383
|
+
description: description || null,
|
|
384
|
+
apply_url: apply_url || null,
|
|
385
|
+
detail_error,
|
|
308
386
|
});
|
|
309
387
|
}
|
|
310
|
-
catch {
|
|
311
|
-
|
|
388
|
+
catch (err) {
|
|
389
|
+
if (err instanceof AuthRequiredError)
|
|
390
|
+
throw err;
|
|
391
|
+
const reason = `fetch failed: ${err?.message || err}`;
|
|
392
|
+
console.error(`[opencli:linkedin] Detail fetch failed for ${job.url}: ${reason}`);
|
|
393
|
+
enriched.push({ ...job, description: null, apply_url: null, detail_error: reason });
|
|
312
394
|
}
|
|
313
395
|
}
|
|
314
396
|
return enriched;
|
|
@@ -320,7 +402,7 @@ cli({
|
|
|
320
402
|
access: 'read',
|
|
321
403
|
description: 'Search LinkedIn jobs',
|
|
322
404
|
domain: 'www.linkedin.com',
|
|
323
|
-
strategy: Strategy.
|
|
405
|
+
strategy: Strategy.COOKIE,
|
|
324
406
|
browser: true,
|
|
325
407
|
args: [
|
|
326
408
|
{ name: 'query', type: 'string', required: true, positional: true, help: 'Job search keywords' },
|
|
@@ -336,8 +418,8 @@ cli({
|
|
|
336
418
|
],
|
|
337
419
|
columns: ['rank', 'title', 'company', 'location', 'listed', 'salary', 'url'],
|
|
338
420
|
func: async (page, kwargs) => {
|
|
339
|
-
const limit =
|
|
340
|
-
const start =
|
|
421
|
+
const limit = parseIntegerArg(kwargs.limit, '--limit', 10, MIN_LIMIT, MAX_LIMIT);
|
|
422
|
+
const start = parseIntegerArg(kwargs.start, '--start', 0, MIN_START);
|
|
341
423
|
const includeDetails = Boolean(kwargs.details);
|
|
342
424
|
const location = (kwargs.location ?? '').trim();
|
|
343
425
|
const keywords = String(kwargs.query ?? '').trim();
|
|
@@ -347,6 +429,7 @@ cli({
|
|
|
347
429
|
if (location)
|
|
348
430
|
searchParams.set('location', location);
|
|
349
431
|
await page.goto(`https://www.linkedin.com/jobs/search/?${searchParams.toString()}`);
|
|
432
|
+
await assertLinkedInAuthenticated(page, 'LinkedIn search');
|
|
350
433
|
await page.wait({ text: 'Jobs', timeout: 10 });
|
|
351
434
|
const companyIds = await resolveCompanyIds(page, kwargs.company);
|
|
352
435
|
const input = {
|
|
@@ -366,3 +449,17 @@ cli({
|
|
|
366
449
|
return enrichJobDetails(page, data);
|
|
367
450
|
},
|
|
368
451
|
});
|
|
452
|
+
|
|
453
|
+
export const __test__ = {
|
|
454
|
+
parseCsvArg,
|
|
455
|
+
parseIntegerArg,
|
|
456
|
+
mapFilterValues,
|
|
457
|
+
decodeLinkedinRedirect,
|
|
458
|
+
looksLinkedInAuthWallText,
|
|
459
|
+
assertLinkedInAuthenticated,
|
|
460
|
+
enrichJobDetails,
|
|
461
|
+
EXPERIENCE_LEVELS,
|
|
462
|
+
JOB_TYPES,
|
|
463
|
+
DATE_POSTED,
|
|
464
|
+
REMOTE_TYPES,
|
|
465
|
+
};
|