@jackwener/opencli 1.8.0 → 1.8.1

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.
Files changed (153) hide show
  1. package/README.md +8 -49
  2. package/README.zh-CN.md +8 -52
  3. package/cli-manifest.json +1796 -191
  4. package/clis/_atlassian/shared.js +577 -0
  5. package/clis/_atlassian/shared.test.js +170 -0
  6. package/clis/bilibili/comment.js +125 -0
  7. package/clis/bilibili/comment.test.js +153 -0
  8. package/clis/bilibili/comments.js +116 -21
  9. package/clis/bilibili/comments.test.js +77 -18
  10. package/clis/bilibili/subtitle.js +76 -31
  11. package/clis/bilibili/subtitle.test.js +156 -9
  12. package/clis/bilibili/utils.js +63 -5
  13. package/clis/bilibili/utils.test.js +45 -1
  14. package/clis/chess/analyze.js +35 -0
  15. package/clis/chess/analyze.test.js +79 -0
  16. package/clis/chess/game.js +114 -0
  17. package/clis/chess/game.test.js +178 -0
  18. package/clis/chess/games.js +67 -0
  19. package/clis/chess/games.test.js +164 -0
  20. package/clis/chess/stats.js +32 -0
  21. package/clis/chess/stats.test.js +79 -0
  22. package/clis/chess/utils.js +170 -0
  23. package/clis/chess/utils.test.js +230 -0
  24. package/clis/confluence/commands.test.js +195 -0
  25. package/clis/confluence/create.js +39 -0
  26. package/clis/confluence/page.js +23 -0
  27. package/clis/confluence/search.js +34 -0
  28. package/clis/confluence/shared.js +173 -0
  29. package/clis/confluence/update.js +38 -0
  30. package/clis/douyin/hashtag.js +84 -23
  31. package/clis/douyin/hashtag.test.js +113 -0
  32. package/clis/geogebra/add-circle.js +46 -0
  33. package/clis/geogebra/add-line.js +35 -0
  34. package/clis/geogebra/add-point.js +27 -0
  35. package/clis/geogebra/add-polygon.js +25 -0
  36. package/clis/geogebra/eval.js +35 -0
  37. package/clis/geogebra/geogebra.test.js +175 -0
  38. package/clis/geogebra/hexagon.js +62 -0
  39. package/clis/geogebra/info.js +72 -0
  40. package/clis/geogebra/list.js +35 -0
  41. package/clis/geogebra/triangle.js +60 -0
  42. package/clis/geogebra/utils.js +271 -0
  43. package/clis/jira/attachments.js +28 -0
  44. package/clis/jira/commands.test.js +287 -0
  45. package/clis/jira/comments.js +28 -0
  46. package/clis/jira/issue.js +28 -0
  47. package/clis/jira/links.js +28 -0
  48. package/clis/jira/search.js +47 -0
  49. package/clis/jira/shared.js +256 -0
  50. package/clis/linkedin/job-detail.js +167 -0
  51. package/clis/linkedin/job-detail.test.js +38 -0
  52. package/clis/linkedin/jobs-preferences.js +113 -0
  53. package/clis/linkedin/jobs-preferences.test.js +43 -0
  54. package/clis/linkedin/post-analytics.js +74 -0
  55. package/clis/linkedin/post-analytics.test.js +40 -0
  56. package/clis/linkedin/posts-core.js +241 -0
  57. package/clis/linkedin/posts.js +22 -0
  58. package/clis/linkedin/posts.test.js +40 -0
  59. package/clis/linkedin/profile-analytics.js +104 -0
  60. package/clis/linkedin/profile-analytics.test.js +67 -0
  61. package/clis/linkedin/profile-experience.js +671 -0
  62. package/clis/linkedin/profile-experience.test.js +152 -0
  63. package/clis/linkedin/profile-projects.js +311 -0
  64. package/clis/linkedin/profile-projects.test.js +111 -0
  65. package/clis/linkedin/profile-read.js +148 -0
  66. package/clis/linkedin/profile-read.test.js +77 -0
  67. package/clis/linkedin/services-read.js +213 -0
  68. package/clis/linkedin/services-read.test.js +105 -0
  69. package/clis/linkedin/shared.js +124 -0
  70. package/clis/linkedin/timeline.js +14 -7
  71. package/clis/notebooklm/add-source.js +269 -0
  72. package/clis/notebooklm/add-source.test.js +97 -0
  73. package/clis/notebooklm/create.js +76 -0
  74. package/clis/notebooklm/create.test.js +58 -0
  75. package/clis/notebooklm/generate-audio.js +91 -0
  76. package/clis/notebooklm/generate-audio.test.js +63 -0
  77. package/clis/notebooklm/generate-slides.js +106 -0
  78. package/clis/notebooklm/generate-slides.test.js +75 -0
  79. package/clis/notebooklm/open.test.js +10 -10
  80. package/clis/notebooklm/rpc.js +20 -6
  81. package/clis/notebooklm/rpc.test.js +27 -1
  82. package/clis/notebooklm/utils.js +100 -24
  83. package/clis/notebooklm/utils.test.js +60 -1
  84. package/clis/notebooklm/write-note.js +103 -0
  85. package/clis/notebooklm/write-note.test.js +70 -0
  86. package/clis/pixiv/detail.js +41 -34
  87. package/clis/pixiv/detail.test.js +93 -0
  88. package/clis/pixiv/user.js +36 -31
  89. package/clis/pixiv/user.test.js +100 -0
  90. package/clis/pixiv/utils.js +56 -7
  91. package/clis/suno/generate.js +5 -0
  92. package/clis/suno/generate.test.js +9 -0
  93. package/clis/suno/status.js +3 -2
  94. package/clis/suno/utils.js +33 -24
  95. package/clis/suno/utils.test.js +106 -0
  96. package/clis/twitter/followers.js +6 -2
  97. package/clis/twitter/followers.test.js +19 -1
  98. package/clis/twitter/following.js +14 -5
  99. package/clis/twitter/following.test.js +29 -0
  100. package/clis/twitter/likes.js +12 -4
  101. package/clis/twitter/likes.test.js +26 -1
  102. package/clis/twitter/list-add.js +1 -1
  103. package/clis/twitter/list-remove.js +1 -1
  104. package/clis/twitter/notifications.js +4 -4
  105. package/clis/twitter/post.js +62 -4
  106. package/clis/twitter/post.test.js +35 -3
  107. package/clis/twitter/profile.js +81 -28
  108. package/clis/twitter/profile.test.js +113 -2
  109. package/clis/twitter/quote.js +9 -4
  110. package/clis/twitter/reply.js +13 -10
  111. package/clis/twitter/reply.test.js +41 -0
  112. package/clis/twitter/search.js +1 -1
  113. package/clis/twitter/search.test.js +35 -0
  114. package/clis/twitter/shared.js +11 -0
  115. package/clis/twitter/shared.test.js +37 -1
  116. package/clis/twitter/utils.js +53 -16
  117. package/clis/upwork/detail.js +132 -0
  118. package/clis/upwork/feed.js +109 -0
  119. package/clis/upwork/search.js +115 -0
  120. package/clis/upwork/upwork.test.js +566 -0
  121. package/clis/upwork/utils.js +323 -0
  122. package/clis/weread/book-search.js +438 -0
  123. package/clis/weread/book-search.test.js +242 -0
  124. package/clis/weread/search-regression.test.js +80 -0
  125. package/clis/weread/search.js +17 -2
  126. package/clis/xiaohongshu/creator-note-detail.js +165 -28
  127. package/clis/xiaohongshu/creator-note-detail.test.js +186 -37
  128. package/clis/xiaohongshu/creator-notes.js +251 -2
  129. package/clis/xiaohongshu/creator-notes.test.js +79 -2
  130. package/clis/xiaohongshu/download.js +97 -39
  131. package/clis/xiaohongshu/download.test.js +201 -0
  132. package/clis/zhihu/answer-comments.js +2 -21
  133. package/clis/zhihu/answer-detail.js +2 -31
  134. package/clis/zhihu/collection.js +2 -14
  135. package/clis/zhihu/collection.test.js +4 -3
  136. package/clis/zhihu/question.js +1 -9
  137. package/clis/zhihu/question.test.js +2 -2
  138. package/clis/zhihu/search.js +1 -12
  139. package/clis/zhihu/search.test.js +2 -2
  140. package/clis/zhihu/text.js +29 -0
  141. package/clis/zhihu/text.test.js +24 -0
  142. package/dist/src/browser/network-cache.js +13 -1
  143. package/dist/src/browser/network-cache.test.js +17 -0
  144. package/dist/src/download/index.js +13 -1
  145. package/dist/src/download/index.test.js +23 -1
  146. package/dist/src/download/media-download.test.js +3 -1
  147. package/dist/src/download/progress.js +2 -2
  148. package/dist/src/download/progress.test.js +12 -1
  149. package/dist/src/output.js +11 -1
  150. package/dist/src/output.test.js +6 -0
  151. package/dist/src/registry.js +1 -0
  152. package/dist/src/registry.test.js +11 -0
  153. package/package.json +1 -1
@@ -0,0 +1,47 @@
1
+ import { cli, Strategy } from '@jackwener/opencli/registry';
2
+ import { jiraConfig, issueSummaryRow, jiraRowsOrEmpty, parseJiraLimit } from './shared.js';
3
+ import { atlassianRequest, requirePayloadArray, requirePayloadObject, requireString } from '../_atlassian/shared.js';
4
+
5
+ function searchPath(config) {
6
+ return config.deployment === 'cloud' ? '/rest/api/3/search/jql' : '/rest/api/2/search';
7
+ }
8
+
9
+ function searchPayload(config, jql, limit) {
10
+ const fields = ['summary', 'issuetype', 'status', 'priority', 'assignee', 'updated'];
11
+ if (config.deployment === 'cloud') return { jql, maxResults: limit, fields };
12
+ return { jql, startAt: 0, maxResults: limit, fields };
13
+ }
14
+
15
+ cli({
16
+ site: 'jira',
17
+ name: 'search',
18
+ access: 'read',
19
+ description: 'Search Jira issues with JQL',
20
+ domain: 'atlassian.net',
21
+ strategy: Strategy.PUBLIC,
22
+ browser: false,
23
+ args: [
24
+ { name: 'jql', positional: true, required: true, help: 'JQL query, e.g. "project = PROJ order by updated desc"' },
25
+ { name: 'limit', type: 'int', default: 20, help: 'Max issues to return (1-100)' },
26
+ ],
27
+ columns: ['key', 'summary', 'issueType', 'status', 'priority', 'assignee', 'updated', 'url'],
28
+ func: async (args) => {
29
+ const config = jiraConfig();
30
+ const jql = requireString(args.jql, 'JQL');
31
+ const limit = parseJiraLimit(args.limit, 20, 100);
32
+ const data = await atlassianRequest(config, searchPath(config), {
33
+ method: 'POST',
34
+ body: searchPayload(config, jql, limit),
35
+ label: 'jira search',
36
+ });
37
+ const payload = requirePayloadObject(data, 'jira search');
38
+ const issues = requirePayloadArray(payload.issues, 'jira search issues');
39
+ return jiraRowsOrEmpty(
40
+ issues.map((issue) => issueSummaryRow(issue, config)),
41
+ 'jira search',
42
+ `No Jira issues matched "${jql}".`,
43
+ );
44
+ },
45
+ });
46
+
47
+ export const __test__ = { searchPath, searchPayload };
@@ -0,0 +1,256 @@
1
+ import {
2
+ adfToMarkdown,
3
+ atlassianRequest,
4
+ getJiraConfig,
5
+ htmlToMarkdown,
6
+ parseLimit,
7
+ queryString,
8
+ requireNonEmptyRows,
9
+ requirePayloadArray,
10
+ requirePayloadObject,
11
+ requirePayloadString,
12
+ requireString,
13
+ } from '../_atlassian/shared.js';
14
+ import { ArgumentError } from '@jackwener/opencli/errors';
15
+
16
+ const DEFAULT_ISSUE_FIELDS = [
17
+ 'summary',
18
+ 'issuetype',
19
+ 'status',
20
+ 'priority',
21
+ 'labels',
22
+ 'description',
23
+ 'comment',
24
+ 'attachment',
25
+ 'issuelinks',
26
+ 'fixVersions',
27
+ 'versions',
28
+ 'components',
29
+ 'project',
30
+ 'reporter',
31
+ 'assignee',
32
+ 'created',
33
+ 'updated',
34
+ ];
35
+
36
+ function jiraApiPrefix(config) {
37
+ return `/rest/api/${config.deployment === 'cloud' ? '3' : '2'}`;
38
+ }
39
+
40
+ export function jiraApiPath(config, resource, params) {
41
+ return `${jiraApiPrefix(config)}${resource.startsWith('/') ? resource : `/${resource}`}${params ? queryString(params) : ''}`;
42
+ }
43
+
44
+ export async function jiraRequest(config, resource, options = {}) {
45
+ return atlassianRequest(config, jiraApiPath(config, resource, options.params), options);
46
+ }
47
+
48
+ function configuredFieldNames() {
49
+ return {
50
+ acceptanceCriteria: process.env.ATLASSIAN_JIRA_ACCEPTANCE_FIELD?.trim() || '',
51
+ sprint: process.env.ATLASSIAN_JIRA_SPRINT_FIELD?.trim() || '',
52
+ storyPoints: process.env.ATLASSIAN_JIRA_STORY_POINTS_FIELD?.trim() || '',
53
+ };
54
+ }
55
+
56
+ function issueFields(extraFields = []) {
57
+ const configured = Object.values(configuredFieldNames()).filter(Boolean);
58
+ return [...new Set([...DEFAULT_ISSUE_FIELDS, ...configured, ...extraFields.filter(Boolean)])].join(',');
59
+ }
60
+
61
+ export function parseJiraLimit(value, fallback = 20, max = 100) {
62
+ return parseLimit(value, fallback, max, 'jira limit');
63
+ }
64
+
65
+ export function requireIssueKey(value) {
66
+ const key = requireString(value, 'Jira issue key');
67
+ if (!/^[A-Za-z][A-Za-z0-9_]+-\d+$/.test(key)) {
68
+ throw new ArgumentError(`Invalid Jira issue key: ${key}`, 'Expected a key like PROJECT-123.');
69
+ }
70
+ return key.toUpperCase();
71
+ }
72
+
73
+ function displayUser(user) {
74
+ if (!user || typeof user !== 'object') return '';
75
+ return String(user.displayName ?? user.name ?? user.emailAddress ?? user.accountId ?? '');
76
+ }
77
+
78
+ function valueName(value) {
79
+ if (!value) return '';
80
+ if (typeof value === 'string') return value;
81
+ if (typeof value === 'object') return String(value.name ?? value.value ?? value.key ?? value.id ?? '');
82
+ return String(value);
83
+ }
84
+
85
+ function valueNames(values) {
86
+ return Array.isArray(values) ? values.map(valueName).filter(Boolean) : [];
87
+ }
88
+
89
+ export function jiraBodyToMarkdown(raw, rendered) {
90
+ if (rendered) return htmlToMarkdown(rendered);
91
+ if (raw && typeof raw === 'object') return adfToMarkdown(raw);
92
+ if (typeof raw === 'string') return raw.trim();
93
+ return '';
94
+ }
95
+
96
+ export function normalizeComment(comment) {
97
+ const row = requirePayloadObject(comment, 'jira comment');
98
+ return {
99
+ id: requirePayloadString(row.id, 'comment id', 'jira comment'),
100
+ author: displayUser(row.author),
101
+ created: row.created ? String(row.created) : '',
102
+ updated: row.updated ? String(row.updated) : undefined,
103
+ markdown: jiraBodyToMarkdown(row.body, row.renderedBody),
104
+ };
105
+ }
106
+
107
+ export function normalizeAttachment(attachment) {
108
+ const row = requirePayloadObject(attachment, 'jira attachment');
109
+ return {
110
+ id: requirePayloadString(row.id, 'attachment id', 'jira attachment'),
111
+ filename: requirePayloadString(row.filename, 'filename', 'jira attachment'),
112
+ mimeType: row.mimeType ? String(row.mimeType) : undefined,
113
+ size: row.size != null ? Number(row.size) : undefined,
114
+ url: requirePayloadString(row.content ?? row.self, 'attachment url', 'jira attachment'),
115
+ };
116
+ }
117
+
118
+ export function normalizeIssueLink(link) {
119
+ const row = requirePayloadObject(link, 'jira issue link');
120
+ const outward = row.outwardIssue;
121
+ const inward = row.inwardIssue;
122
+ const issue = outward ?? inward ?? {};
123
+ const key = requirePayloadString(issue.key, 'linked issue key', 'jira issue link');
124
+ return {
125
+ key,
126
+ type: String(row.type?.name ?? (outward ? row.type?.outward : row.type?.inward) ?? ''),
127
+ direction: outward ? 'outward' : 'inward',
128
+ };
129
+ }
130
+
131
+ function customValueToMarkdown(value) {
132
+ if (!value) return '';
133
+ if (typeof value === 'string') return value.trim();
134
+ if (value && typeof value === 'object' && value.type === 'doc') return adfToMarkdown(value);
135
+ if (Array.isArray(value)) return value.map(valueName).filter(Boolean).join(', ');
136
+ return valueName(value);
137
+ }
138
+
139
+ function inlineComments(fields, key, options) {
140
+ if (options.comments !== undefined) return requirePayloadArray(options.comments, `jira issue ${key} comments`);
141
+ if (options.requireNestedCollections === false) return [];
142
+ const commentBlock = requirePayloadObject(fields.comment, `jira issue ${key} comment field`);
143
+ return requirePayloadArray(commentBlock.comments, `jira issue ${key} comment field comments`);
144
+ }
145
+
146
+ export function normalizeJiraIssue(issue, config, options = {}) {
147
+ const row = requirePayloadObject(issue, 'jira issue');
148
+ const key = requirePayloadString(row.key, 'issue key', 'jira issue');
149
+ const fields = requirePayloadObject(row.fields, `jira issue ${key} fields`);
150
+ const rendered = row.renderedFields && typeof row.renderedFields === 'object' && !Array.isArray(row.renderedFields)
151
+ ? row.renderedFields
152
+ : {};
153
+ const custom = configuredFieldNames();
154
+ const comments = inlineComments(fields, key, options);
155
+ const requireNestedCollections = options.requireNestedCollections !== false;
156
+ const attachments = requireNestedCollections
157
+ ? requirePayloadArray(fields.attachment, `jira issue ${key} attachment field`)
158
+ : [];
159
+ const issueLinks = requireNestedCollections
160
+ ? requirePayloadArray(fields.issuelinks, `jira issue ${key} issuelinks field`)
161
+ : [];
162
+ const normalized = {
163
+ key,
164
+ id: row.id != null ? String(row.id) : '',
165
+ url: `${config.baseUrl}/browse/${key}`,
166
+ summary: String(fields.summary ?? ''),
167
+ issueType: valueName(fields.issuetype) || undefined,
168
+ status: valueName(fields.status) || undefined,
169
+ priority: valueName(fields.priority) || undefined,
170
+ project: valueName(fields.project) || undefined,
171
+ reporter: displayUser(fields.reporter) || undefined,
172
+ assignee: displayUser(fields.assignee) || undefined,
173
+ labels: Array.isArray(fields.labels) ? fields.labels.map(String) : [],
174
+ description: {
175
+ raw: fields.description ?? null,
176
+ markdown: jiraBodyToMarkdown(fields.description, rendered.description),
177
+ },
178
+ comments: comments.map(normalizeComment),
179
+ attachments: attachments.map(normalizeAttachment),
180
+ linkedIssues: issueLinks.map(normalizeIssueLink),
181
+ fixVersions: valueNames(fields.fixVersions),
182
+ affectedVersions: valueNames(fields.versions),
183
+ components: valueNames(fields.components),
184
+ created: fields.created ? String(fields.created) : undefined,
185
+ updated: fields.updated ? String(fields.updated) : undefined,
186
+ };
187
+ if (custom.acceptanceCriteria && fields[custom.acceptanceCriteria] !== undefined) {
188
+ normalized.acceptanceCriteria = {
189
+ raw: fields[custom.acceptanceCriteria],
190
+ markdown: customValueToMarkdown(fields[custom.acceptanceCriteria]),
191
+ };
192
+ }
193
+ if (custom.sprint && fields[custom.sprint] !== undefined) {
194
+ normalized.sprint = customValueToMarkdown(fields[custom.sprint]);
195
+ }
196
+ if (custom.storyPoints && fields[custom.storyPoints] !== undefined) {
197
+ normalized.storyPoints = Number(fields[custom.storyPoints]);
198
+ }
199
+ return normalized;
200
+ }
201
+
202
+ export function issueSummaryRow(issue, config) {
203
+ const normalized = normalizeJiraIssue(issue, config, { comments: [], requireNestedCollections: false });
204
+ return {
205
+ key: normalized.key,
206
+ summary: normalized.summary,
207
+ issueType: normalized.issueType,
208
+ status: normalized.status,
209
+ priority: normalized.priority,
210
+ assignee: normalized.assignee,
211
+ updated: normalized.updated,
212
+ url: normalized.url,
213
+ };
214
+ }
215
+
216
+ export async function fetchIssue(config, key, extraFields = []) {
217
+ const issue = await jiraRequest(config, `/issue/${encodeURIComponent(key)}`, {
218
+ params: {
219
+ fields: issueFields(extraFields),
220
+ expand: 'renderedFields',
221
+ },
222
+ label: `jira issue ${key}`,
223
+ });
224
+ return requirePayloadObject(issue, `jira issue ${key}`);
225
+ }
226
+
227
+ export async function fetchComments(config, key, limit = 100) {
228
+ const maxResults = parseJiraLimit(limit, 100, 100);
229
+ const data = await jiraRequest(config, `/issue/${encodeURIComponent(key)}/comment`, {
230
+ params: { startAt: 0, maxResults, expand: 'renderedBody' },
231
+ label: `jira comments ${key}`,
232
+ });
233
+ const payload = requirePayloadObject(data, `jira comments ${key}`);
234
+ return requirePayloadArray(payload.comments, `jira comments ${key}`);
235
+ }
236
+
237
+ export function jiraRowsOrEmpty(rows, label, hint) {
238
+ return requireNonEmptyRows(rows, label, hint);
239
+ }
240
+
241
+ export function jiraConfig() {
242
+ return getJiraConfig();
243
+ }
244
+
245
+ export const __test__ = {
246
+ configuredFieldNames,
247
+ fetchIssue,
248
+ issueSummaryRow,
249
+ jiraApiPath,
250
+ jiraBodyToMarkdown,
251
+ normalizeAttachment,
252
+ normalizeComment,
253
+ normalizeIssueLink,
254
+ normalizeJiraIssue,
255
+ requireIssueKey,
256
+ };
@@ -0,0 +1,167 @@
1
+ import { cli, Strategy } from '@jackwener/opencli/registry';
2
+ import { ArgumentError, CommandExecutionError } from '@jackwener/opencli/errors';
3
+ import {
4
+ assertLinkedInAuthenticated,
5
+ assertSafeLinkedinUrl,
6
+ normalizeHttpUrl,
7
+ normalizeWhitespace,
8
+ unwrapEvaluateResult,
9
+ } from './shared.js';
10
+
11
+ function normalizeJobUrl(value) {
12
+ const url = assertSafeLinkedinUrl(value, 'job-url');
13
+ const parsed = new URL(url);
14
+ const match = parsed.pathname.match(/^\/jobs\/view\/(\d+)/) || parsed.search.match(/[?&]currentJobId=(\d+)/);
15
+ if (!match) throw new ArgumentError('job-url must be a https://www.linkedin.com/jobs/view/<id> URL');
16
+ return `https://www.linkedin.com/jobs/search/?currentJobId=${match[1]}`;
17
+ }
18
+
19
+ function decodeLinkedinRedirect(url) {
20
+ if (!url) return '';
21
+ try {
22
+ const parsed = new URL(url);
23
+ if (parsed.pathname === '/redir/redirect/') return normalizeHttpUrl(parsed.searchParams.get('url') || '');
24
+ } catch {}
25
+ return normalizeHttpUrl(url);
26
+ }
27
+
28
+ function buildExtractionScript() {
29
+ return String.raw`(() => {
30
+ const clean = (s) => String(s || '').replace(/[\u00a0\u202f]+/g, ' ').replace(/\s+/g, ' ').trim();
31
+ const readRenderedDescription = () => {
32
+ const expanders = Array.from(document.querySelectorAll('button, a'))
33
+ .filter((el) => /\b(show more|see more|more)\b/i.test(clean(el.innerText || el.textContent || el.getAttribute('aria-label') || '')));
34
+ for (const expander of expanders.slice(0, 3)) {
35
+ try { expander.click(); } catch {}
36
+ }
37
+ const aboutHeading = Array.from(document.querySelectorAll('h1,h2,h3,h4'))
38
+ .find((el) => /^about the job$/i.test(clean(el.innerText || el.textContent || '')));
39
+ const aboutRoot = aboutHeading?.closest('section, article, div');
40
+ if (aboutRoot) {
41
+ const value = clean((aboutRoot.innerText || aboutRoot.textContent || '').replace(/^About the job\s*/i, ''));
42
+ if (value && !/^show more$/i.test(value)) return value;
43
+ }
44
+ const candidates = Array.from(document.querySelectorAll(
45
+ '.jobs-description-content__text, .jobs-box__html-content, .jobs-description__content, [class*="jobs-description"], [class*="description-content"]'
46
+ ));
47
+ for (const candidate of candidates) {
48
+ const value = clean(candidate.innerText || candidate.textContent || '');
49
+ if (value && value.length > 40 && !/^show more$/i.test(value)) return value.replace(/^About the job\s*/i, '');
50
+ }
51
+ return '';
52
+ };
53
+ const parseInlineJobData = () => {
54
+ const codes = Array.from(document.querySelectorAll('code[id^="bpr-guid-"]'));
55
+ for (const code of codes) {
56
+ let payload;
57
+ try { payload = JSON.parse(code.textContent || '{}'); } catch { continue; }
58
+ const included = Array.isArray(payload.included) ? payload.included : [];
59
+ const topCard = included.find((item) => item && (item.jobPostingTitle || item.primaryDescription || item.tertiaryDescription));
60
+ if (!topCard) continue;
61
+ const apply = included.find((item) => item && item.companyApplyUrl);
62
+ const workplace = included.find((item) => item && (item.workplaceTypeEnum || item.localizedName));
63
+ const company = included.find((item) => item && item.name && /company/i.test(String(item.entityUrn || item.$type || '')));
64
+ return {
65
+ url: location.href,
66
+ title: clean(topCard.jobPostingTitle || topCard.title?.text || topCard.title?.accessibilityText || ''),
67
+ company: clean(topCard.primaryDescription?.text || company?.name || ''),
68
+ company_url: clean(topCard.primaryDescription?.attributesV2?.[0]?.detailData?.hyperlink || topCard.logo?.actionTarget || ''),
69
+ location: clean(topCard.navigationBarSubtitle || topCard.secondaryDescription?.text || ''),
70
+ workplace_type: clean(workplace?.localizedName || workplace?.workplaceTypeEnum || ''),
71
+ job_type: clean((topCard.jobInsightsV2ResolutionResults || []).flatMap((x) => x?.jobInsightViewModel?.description || []).map((x) => x?.text?.text || '').find((x) => /full-time|part-time|contract|internship/i.test(x)) || ''),
72
+ applicants: clean((topCard.tertiaryDescription?.text || '').match(/Over\s+\d+|\d[\d,]*\s+people clicked apply|\d[\d,]*\s+applicants?/i)?.[0] || ''),
73
+ listed: clean((topCard.tertiaryDescription?.text || '').match(/\d+\s+(?:hour|hours|day|days|week|weeks|month|months)\s+ago/i)?.[0] || ''),
74
+ apply_url: clean(apply?.companyApplyUrl || ''),
75
+ description: '',
76
+ };
77
+ }
78
+ return null;
79
+ };
80
+ const inline = parseInlineJobData();
81
+ const renderedDescription = readRenderedDescription();
82
+ if (inline && inline.title) return { ...inline, description: clean(inline.description || renderedDescription) };
83
+ const text = document.body ? document.body.innerText || '' : '';
84
+ const lines = text.split(/\n+/).map(clean).filter(Boolean);
85
+ const h1 = clean(document.querySelector('h1')?.innerText || document.querySelector('h1')?.textContent || document.querySelector('.job-details-jobs-unified-top-card__job-title, [class*="job-title"]')?.textContent || '');
86
+ const companyLink = document.querySelector('a[href*="/company/"]');
87
+ const company = clean(companyLink?.innerText || companyLink?.textContent || '');
88
+ const company_url = companyLink?.href ? new URL(companyLink.href, location.origin).toString().replace(/[?#].*$/, '') : '';
89
+ const description = renderedDescription;
90
+ const applyLink = Array.from(document.querySelectorAll('a[href], button')).find((el) => {
91
+ const label = clean(el.innerText || el.textContent || el.getAttribute('aria-label') || '');
92
+ return /\b(apply|easy apply)\b/i.test(label);
93
+ });
94
+ const apply_url = applyLink?.href ? new URL(applyLink.href, location.origin).toString() : '';
95
+ const fullText = lines.join(' ');
96
+ const workplaceMatch = fullText.match(/\b(Remote|Hybrid|On-site|Onsite)\b/i);
97
+ const jobTypeMatch = fullText.match(/\b(Full-time|Part-time|Contract|Temporary|Internship|Volunteer)\b/i);
98
+ const applicantsMatch = fullText.match(/(\d[\d,]*)\s+applicants?/i);
99
+ const listedMatch = fullText.match(/(?:Reposted|Posted|Listed)\s+(\d+\s+(?:hour|hours|day|days|week|weeks|month|months)\s+ago)/i);
100
+ const locationLine = lines.find((line) => /\b(Remote|Hybrid|On-site|Onsite)\b/i.test(line) && line.length < 180)
101
+ || lines.find((line) => /,\s*[A-Z][A-Za-z\s]+/.test(line) && line.length < 120)
102
+ || '';
103
+ return {
104
+ url: location.href,
105
+ title: h1,
106
+ company,
107
+ company_url,
108
+ location: locationLine,
109
+ workplace_type: workplaceMatch ? workplaceMatch[1] : '',
110
+ job_type: jobTypeMatch ? jobTypeMatch[1] : '',
111
+ applicants: applicantsMatch ? applicantsMatch[1] : '',
112
+ listed: listedMatch ? listedMatch[1] : '',
113
+ apply_url,
114
+ description,
115
+ };
116
+ })()`;
117
+ }
118
+
119
+ function normalizeDetail(row) {
120
+ if (!row || typeof row !== 'object') {
121
+ throw new CommandExecutionError('LinkedIn job detail returned malformed extraction payload');
122
+ }
123
+ const title = normalizeWhitespace(row.title);
124
+ if (!title) throw new CommandExecutionError('LinkedIn job detail could not find a job title');
125
+ return {
126
+ title,
127
+ company: normalizeWhitespace(row.company),
128
+ location: normalizeWhitespace(row.location),
129
+ workplace_type: normalizeWhitespace(row.workplace_type),
130
+ job_type: normalizeWhitespace(row.job_type),
131
+ applicants: normalizeWhitespace(row.applicants),
132
+ listed: normalizeWhitespace(row.listed),
133
+ apply_url: decodeLinkedinRedirect(normalizeWhitespace(row.apply_url)),
134
+ company_url: normalizeHttpUrl(row.company_url),
135
+ url: normalizeHttpUrl(row.url),
136
+ description: normalizeWhitespace(row.description),
137
+ };
138
+ }
139
+
140
+ cli({
141
+ site: 'linkedin',
142
+ name: 'job-detail',
143
+ access: 'read',
144
+ description: 'Read one LinkedIn job page with description, apply URL, workplace type, applicants, and company metadata',
145
+ domain: 'www.linkedin.com',
146
+ strategy: Strategy.COOKIE,
147
+ browser: true,
148
+ args: [
149
+ { name: 'job-url', type: 'string', required: true, positional: true, help: 'Exact LinkedIn job URL, e.g. https://www.linkedin.com/jobs/view/123/' },
150
+ ],
151
+ columns: ['title', 'company', 'location', 'workplace_type', 'job_type', 'applicants', 'listed', 'apply_url', 'company_url', 'url', 'description'],
152
+ func: async (page, args) => {
153
+ if (!page) throw new CommandExecutionError('Browser session required for linkedin job-detail');
154
+ const jobUrl = normalizeJobUrl(args['job-url']);
155
+ await page.goto(jobUrl);
156
+ await page.wait(4);
157
+ await assertLinkedInAuthenticated(page, 'LinkedIn job-detail');
158
+ const row = unwrapEvaluateResult(await page.evaluate(buildExtractionScript()));
159
+ return [normalizeDetail(row)];
160
+ },
161
+ });
162
+
163
+ export const __test__ = {
164
+ normalizeJobUrl,
165
+ decodeLinkedinRedirect,
166
+ normalizeDetail,
167
+ };
@@ -0,0 +1,38 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { getRegistry } from '@jackwener/opencli/registry';
3
+ import { ArgumentError, CommandExecutionError } from '@jackwener/opencli/errors';
4
+ import './job-detail.js';
5
+
6
+ const { normalizeJobUrl, decodeLinkedinRedirect, normalizeDetail } = await import('./job-detail.js').then((m) => m.__test__);
7
+
8
+ describe('linkedin job-detail adapter', () => {
9
+ const command = getRegistry().get('linkedin/job-detail');
10
+
11
+ it('registers command shape', () => {
12
+ expect(command).toBeDefined();
13
+ expect(command.strategy).toBe('cookie');
14
+ expect(command.browser).toBe(true);
15
+ expect(command.columns).toContain('description');
16
+ });
17
+
18
+ it('normalizes exact LinkedIn job urls', () => {
19
+ expect(normalizeJobUrl('https://www.linkedin.com/jobs/view/123456?x=1')).toBe('https://www.linkedin.com/jobs/search/?currentJobId=123456');
20
+ });
21
+
22
+ it('rejects non-job URLs', () => {
23
+ expect(() => normalizeJobUrl('https://www.linkedin.com/feed/')).toThrow(ArgumentError);
24
+ expect(() => normalizeJobUrl('https://example.com/jobs/view/1')).toThrow(ArgumentError);
25
+ });
26
+
27
+ it('decodes LinkedIn redirect apply urls', () => {
28
+ expect(decodeLinkedinRedirect('https://www.linkedin.com/redir/redirect/?url=https%3A%2F%2Fexample.com%2Fapply')).toBe('https://example.com/apply');
29
+ expect(decodeLinkedinRedirect('https://www.linkedin.com/redir/redirect/?url=javascript%3Aalert(1)')).toBe('');
30
+ expect(decodeLinkedinRedirect('javascript:alert(1)')).toBe('');
31
+ });
32
+
33
+ it('requires stable title in extracted detail', () => {
34
+ expect(() => normalizeDetail({ title: '' })).toThrow(CommandExecutionError);
35
+ expect(normalizeDetail({ title: 'Senior Engineer', company: 'Acme', description: 'Build things' }))
36
+ .toMatchObject({ title: 'Senior Engineer', company: 'Acme', description: 'Build things' });
37
+ });
38
+ });
@@ -0,0 +1,113 @@
1
+ import { cli, Strategy } from '@jackwener/opencli/registry';
2
+ import { CommandExecutionError } from '@jackwener/opencli/errors';
3
+ import {
4
+ assertLinkedInAuthenticated,
5
+ normalizeWhitespace,
6
+ unwrapEvaluateResult,
7
+ } from './shared.js';
8
+
9
+ const PREFERENCES_URL = 'https://www.linkedin.com/jobs/preferences/';
10
+ const ALERTS_URL = 'https://www.linkedin.com/jobs/alerts/';
11
+
12
+ function inferOpenToWork(text) {
13
+ const normalized = normalizeWhitespace(text).toLowerCase();
14
+ if (/\bopen to work\b.{0,80}\b(on|status on|visible to recruiters|job preferences visible)\b/.test(normalized)) return 'on';
15
+ if (/\bopen to work\b.{0,80}\b(off|status off|not visible|turned off|inactive)\b/.test(normalized)) return 'off';
16
+ if (/\bopen to work\b/.test(normalized) && /\b(off|not visible|turned off|inactive)\b/.test(normalized)) return 'off';
17
+ if (/\bopen to work\b/.test(normalized) && /\b(on|visible|actively|turned on)\b/.test(normalized)) return 'on';
18
+ if (/\bopen to work\b/.test(normalized)) return 'visible';
19
+ return 'unknown';
20
+ }
21
+
22
+ function buildPreferencesScript() {
23
+ return String.raw`(() => {
24
+ const clean = (s) => String(s || '').replace(/[\u00a0\u202f]+/g, ' ').replace(/\s+/g, ' ').trim();
25
+ const text = document.body ? document.body.innerText || '' : '';
26
+ const preferencesText = text.split(/Top job picks for you|Recommended jobs|Similar jobs|Explore jobs/i)[0] || text;
27
+ const lines = preferencesText.split(/\n+/).map(clean).filter(Boolean);
28
+ const jobTitles = [];
29
+ const locations = [];
30
+ for (const line of lines) {
31
+ if (/senior|engineer|developer|architect|manager|designer|analyst|product/i.test(line) && line.length < 90) jobTitles.push(line);
32
+ if (/(remote|india|bangalore|bengaluru|delhi|mumbai|hyderabad|pune|jaipur|within\s+\d+\s+miles?)/i.test(line) && line.length < 120) locations.push(line);
33
+ }
34
+ return {
35
+ preferences_url: location.href,
36
+ raw_preferences: clean(text).slice(0, 3000),
37
+ job_titles: Array.from(new Set(jobTitles)).slice(0, 12),
38
+ locations: Array.from(new Set(locations)).slice(0, 12),
39
+ };
40
+ })()`;
41
+ }
42
+
43
+ function buildAlertsScript() {
44
+ return String.raw`(() => {
45
+ const clean = (s) => String(s || '').replace(/[\u00a0\u202f]+/g, ' ').replace(/\s+/g, ' ').trim();
46
+ const text = document.body ? document.body.innerText || '' : '';
47
+ const lines = text.split(/\n+/).map(clean).filter(Boolean);
48
+ const alerts = [];
49
+ for (let i = 0; i < lines.length; i++) {
50
+ const line = lines[i];
51
+ if (/alert/i.test(line) && line.length < 160) {
52
+ alerts.push([line, lines[i + 1], lines[i + 2]].filter(Boolean).join(' | '));
53
+ }
54
+ }
55
+ return {
56
+ alerts_url: location.href,
57
+ job_alerts: Array.from(new Set(alerts)).slice(0, 20),
58
+ raw_preferences: clean(text).slice(0, 3000),
59
+ };
60
+ })()`;
61
+ }
62
+
63
+ function normalizePreferences(preferences, alerts) {
64
+ if (!preferences || typeof preferences !== 'object') {
65
+ throw new CommandExecutionError('LinkedIn jobs preferences returned malformed preferences payload');
66
+ }
67
+ if (!alerts || typeof alerts !== 'object') {
68
+ throw new CommandExecutionError('LinkedIn jobs preferences returned malformed alerts payload');
69
+ }
70
+ const preferenceText = normalizeWhitespace(preferences.raw_preferences);
71
+ const alertText = normalizeWhitespace(alerts.raw_preferences);
72
+ if (!preferenceText && !alertText) {
73
+ throw new CommandExecutionError('LinkedIn jobs preferences could not find stable preferences content');
74
+ }
75
+ return {
76
+ open_to_work: inferOpenToWork(`${preferenceText} ${alertText}`),
77
+ job_titles: Array.isArray(preferences.job_titles) ? preferences.job_titles.map(normalizeWhitespace).filter(Boolean).join('; ') : '',
78
+ locations: Array.isArray(preferences.locations) ? preferences.locations.map(normalizeWhitespace).filter(Boolean).join('; ') : '',
79
+ job_alerts: Array.isArray(alerts.job_alerts) ? alerts.job_alerts.map(normalizeWhitespace).filter(Boolean).join('; ') : '',
80
+ preferences_url: normalizeWhitespace(preferences.preferences_url),
81
+ alerts_url: normalizeWhitespace(alerts.alerts_url),
82
+ raw_preferences: preferenceText.slice(0, 1200),
83
+ };
84
+ }
85
+
86
+ cli({
87
+ site: 'linkedin',
88
+ name: 'jobs-preferences',
89
+ access: 'read',
90
+ description: 'Read visible LinkedIn Jobs preferences and alert settings without changing them',
91
+ domain: 'www.linkedin.com',
92
+ strategy: Strategy.COOKIE,
93
+ browser: true,
94
+ args: [],
95
+ columns: ['open_to_work', 'job_titles', 'locations', 'job_alerts', 'preferences_url', 'alerts_url', 'raw_preferences'],
96
+ func: async (page) => {
97
+ if (!page) throw new CommandExecutionError('Browser session required for linkedin jobs-preferences');
98
+ await page.goto(PREFERENCES_URL);
99
+ await page.wait(5);
100
+ await assertLinkedInAuthenticated(page, 'LinkedIn jobs-preferences');
101
+ const preferences = unwrapEvaluateResult(await page.evaluate(buildPreferencesScript()));
102
+ await page.goto(ALERTS_URL);
103
+ await page.wait(5);
104
+ await assertLinkedInAuthenticated(page, 'LinkedIn jobs-preferences alerts');
105
+ const alerts = unwrapEvaluateResult(await page.evaluate(buildAlertsScript()));
106
+ return [normalizePreferences(preferences, alerts)];
107
+ },
108
+ });
109
+
110
+ export const __test__ = {
111
+ inferOpenToWork,
112
+ normalizePreferences,
113
+ };
@@ -0,0 +1,43 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { getRegistry } from '@jackwener/opencli/registry';
3
+ import { CommandExecutionError } from '@jackwener/opencli/errors';
4
+ import './jobs-preferences.js';
5
+
6
+ const { inferOpenToWork, normalizePreferences } = await import('./jobs-preferences.js').then((m) => m.__test__);
7
+
8
+ describe('linkedin jobs-preferences adapter', () => {
9
+ const command = getRegistry().get('linkedin/jobs-preferences');
10
+
11
+ it('registers command shape', () => {
12
+ expect(command).toBeDefined();
13
+ expect(command.strategy).toBe('cookie');
14
+ expect(command.browser).toBe(true);
15
+ expect(command.columns).toContain('open_to_work');
16
+ });
17
+
18
+ it('infers Open to Work visible states conservatively', () => {
19
+ expect(inferOpenToWork('Open to work turned off')).toBe('off');
20
+ expect(inferOpenToWork('Open to work turned on visible to recruiters')).toBe('on');
21
+ expect(inferOpenToWork('Open to work')).toBe('visible');
22
+ expect(inferOpenToWork('Job preferences')).toBe('unknown');
23
+ });
24
+
25
+ it('normalizes preferences and alerts', () => {
26
+ const out = normalizePreferences(
27
+ { raw_preferences: 'Open to work turned off', job_titles: ['Senior Software Engineer'], locations: ['Bangalore within 20 miles'], preferences_url: 'https://www.linkedin.com/jobs/preferences/' },
28
+ { raw_preferences: 'Job alert', job_alerts: ['Senior Software Engineer | Bangalore'], alerts_url: 'https://www.linkedin.com/jobs/alerts/' },
29
+ );
30
+ expect(out).toMatchObject({
31
+ open_to_work: 'off',
32
+ job_titles: 'Senior Software Engineer',
33
+ locations: 'Bangalore within 20 miles',
34
+ job_alerts: 'Senior Software Engineer | Bangalore',
35
+ });
36
+ });
37
+
38
+ it('rejects malformed payloads', () => {
39
+ expect(() => normalizePreferences(null, {})).toThrow(CommandExecutionError);
40
+ expect(() => normalizePreferences({}, null)).toThrow(CommandExecutionError);
41
+ expect(() => normalizePreferences({ raw_preferences: '' }, { raw_preferences: '' })).toThrow(CommandExecutionError);
42
+ });
43
+ });