@epsilon-asi/actors 0.0.21 → 0.0.32

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 (198) hide show
  1. package/dist/browser/RuntimeConfig.d.ts +26 -0
  2. package/dist/browser/RuntimeConfig.d.ts.map +1 -1
  3. package/dist/browser/RuntimeConfig.js +29 -1
  4. package/dist/browser/RuntimeConfig.js.map +1 -1
  5. package/dist/core/ActorContext.d.ts +2 -0
  6. package/dist/core/ActorContext.d.ts.map +1 -1
  7. package/dist/core/ActorRunner.d.ts +3 -0
  8. package/dist/core/ActorRunner.d.ts.map +1 -1
  9. package/dist/core/ActorRunner.js +11 -1
  10. package/dist/core/ActorRunner.js.map +1 -1
  11. package/dist/index.d.ts +2 -0
  12. package/dist/index.d.ts.map +1 -1
  13. package/dist/index.js +2 -0
  14. package/dist/index.js.map +1 -1
  15. package/dist/native/CompositeNativeWindowDriver.d.ts +11 -0
  16. package/dist/native/CompositeNativeWindowDriver.d.ts.map +1 -0
  17. package/dist/native/CompositeNativeWindowDriver.js +31 -0
  18. package/dist/native/CompositeNativeWindowDriver.js.map +1 -0
  19. package/dist/native/NativeActionRegistry.d.ts +14 -0
  20. package/dist/native/NativeActionRegistry.d.ts.map +1 -0
  21. package/dist/native/NativeActionRegistry.js +101 -0
  22. package/dist/native/NativeActionRegistry.js.map +1 -0
  23. package/dist/native/NativeAutomation.d.ts +3 -0
  24. package/dist/native/NativeAutomation.d.ts.map +1 -0
  25. package/dist/native/NativeAutomation.js +12 -0
  26. package/dist/native/NativeAutomation.js.map +1 -0
  27. package/dist/native/NativeCoordinateMapper.d.ts +23 -0
  28. package/dist/native/NativeCoordinateMapper.d.ts.map +1 -0
  29. package/dist/native/NativeCoordinateMapper.js +201 -0
  30. package/dist/native/NativeCoordinateMapper.js.map +1 -0
  31. package/dist/native/NativeFileDialogService.d.ts +26 -0
  32. package/dist/native/NativeFileDialogService.d.ts.map +1 -0
  33. package/dist/native/NativeFileDialogService.js +121 -0
  34. package/dist/native/NativeFileDialogService.js.map +1 -0
  35. package/dist/native/NativeImageFinder.d.ts +12 -0
  36. package/dist/native/NativeImageFinder.d.ts.map +1 -0
  37. package/dist/native/NativeImageFinder.js +29 -0
  38. package/dist/native/NativeImageFinder.js.map +1 -0
  39. package/dist/native/NativeKeyboard.d.ts +10 -0
  40. package/dist/native/NativeKeyboard.d.ts.map +1 -0
  41. package/dist/native/NativeKeyboard.js +16 -0
  42. package/dist/native/NativeKeyboard.js.map +1 -0
  43. package/dist/native/NativeMouse.d.ts +38 -0
  44. package/dist/native/NativeMouse.d.ts.map +1 -0
  45. package/dist/native/NativeMouse.js +82 -0
  46. package/dist/native/NativeMouse.js.map +1 -0
  47. package/dist/native/NativeWindowService.d.ts +31 -0
  48. package/dist/native/NativeWindowService.d.ts.map +1 -0
  49. package/dist/native/NativeWindowService.js +183 -0
  50. package/dist/native/NativeWindowService.js.map +1 -0
  51. package/dist/native/UnsupportedNativeAutomation.d.ts +4 -0
  52. package/dist/native/UnsupportedNativeAutomation.d.ts.map +1 -0
  53. package/dist/native/UnsupportedNativeAutomation.js +77 -0
  54. package/dist/native/UnsupportedNativeAutomation.js.map +1 -0
  55. package/dist/native/WindowMatcher.d.ts +4 -0
  56. package/dist/native/WindowMatcher.d.ts.map +1 -0
  57. package/dist/native/WindowMatcher.js +39 -0
  58. package/dist/native/WindowMatcher.js.map +1 -0
  59. package/dist/native/drivers.d.ts +37 -0
  60. package/dist/native/drivers.d.ts.map +1 -0
  61. package/dist/native/drivers.js +2 -0
  62. package/dist/native/drivers.js.map +1 -0
  63. package/dist/native/errors.d.ts +23 -0
  64. package/dist/native/errors.d.ts.map +1 -0
  65. package/dist/native/errors.js +45 -0
  66. package/dist/native/errors.js.map +1 -0
  67. package/dist/native/index.d.ts +13 -0
  68. package/dist/native/index.d.ts.map +1 -0
  69. package/dist/native/index.js +13 -0
  70. package/dist/native/index.js.map +1 -0
  71. package/dist/native/macos/MacOSAccessibilityWindowDriver.d.ts +11 -0
  72. package/dist/native/macos/MacOSAccessibilityWindowDriver.d.ts.map +1 -0
  73. package/dist/native/macos/MacOSAccessibilityWindowDriver.js +180 -0
  74. package/dist/native/macos/MacOSAccessibilityWindowDriver.js.map +1 -0
  75. package/dist/native/macos/MacOSAppleScriptClient.d.ts +24 -0
  76. package/dist/native/macos/MacOSAppleScriptClient.d.ts.map +1 -0
  77. package/dist/native/macos/MacOSAppleScriptClient.js +163 -0
  78. package/dist/native/macos/MacOSAppleScriptClient.js.map +1 -0
  79. package/dist/native/macos/MacOSFileDialogAccessibilityStrategy.d.ts +10 -0
  80. package/dist/native/macos/MacOSFileDialogAccessibilityStrategy.d.ts.map +1 -0
  81. package/dist/native/macos/MacOSFileDialogAccessibilityStrategy.js +12 -0
  82. package/dist/native/macos/MacOSFileDialogAccessibilityStrategy.js.map +1 -0
  83. package/dist/native/macos/MacOSNativeAutomation.d.ts +3 -0
  84. package/dist/native/macos/MacOSNativeAutomation.d.ts.map +1 -0
  85. package/dist/native/macos/MacOSNativeAutomation.js +88 -0
  86. package/dist/native/macos/MacOSNativeAutomation.js.map +1 -0
  87. package/dist/native/nut/NutNativeImageFinder.d.ts +17 -0
  88. package/dist/native/nut/NutNativeImageFinder.d.ts.map +1 -0
  89. package/dist/native/nut/NutNativeImageFinder.js +84 -0
  90. package/dist/native/nut/NutNativeImageFinder.js.map +1 -0
  91. package/dist/native/nut/NutNativeKeyboardDriver.d.ts +8 -0
  92. package/dist/native/nut/NutNativeKeyboardDriver.d.ts.map +1 -0
  93. package/dist/native/nut/NutNativeKeyboardDriver.js +39 -0
  94. package/dist/native/nut/NutNativeKeyboardDriver.js.map +1 -0
  95. package/dist/native/nut/NutNativeMouseDriver.d.ts +8 -0
  96. package/dist/native/nut/NutNativeMouseDriver.d.ts.map +1 -0
  97. package/dist/native/nut/NutNativeMouseDriver.js +24 -0
  98. package/dist/native/nut/NutNativeMouseDriver.js.map +1 -0
  99. package/dist/native/nut/NutNativeScreenDriver.d.ts +6 -0
  100. package/dist/native/nut/NutNativeScreenDriver.d.ts.map +1 -0
  101. package/dist/native/nut/NutNativeScreenDriver.js +12 -0
  102. package/dist/native/nut/NutNativeScreenDriver.js.map +1 -0
  103. package/dist/native/nut/NutNativeWindowDriver.d.ts +6 -0
  104. package/dist/native/nut/NutNativeWindowDriver.d.ts.map +1 -0
  105. package/dist/native/nut/NutNativeWindowDriver.js +53 -0
  106. package/dist/native/nut/NutNativeWindowDriver.js.map +1 -0
  107. package/dist/native/nut/loadNut.d.ts +58 -0
  108. package/dist/native/nut/loadNut.d.ts.map +1 -0
  109. package/dist/native/nut/loadNut.js +25 -0
  110. package/dist/native/nut/loadNut.js.map +1 -0
  111. package/dist/native/types.d.ts +194 -0
  112. package/dist/native/types.d.ts.map +1 -0
  113. package/dist/native/types.js +2 -0
  114. package/dist/native/types.js.map +1 -0
  115. package/dist/native/utils/appleScriptEscape.d.ts +7 -0
  116. package/dist/native/utils/appleScriptEscape.d.ts.map +1 -0
  117. package/dist/native/utils/appleScriptEscape.js +11 -0
  118. package/dist/native/utils/appleScriptEscape.js.map +1 -0
  119. package/dist/native/utils/geometry.d.ts +12 -0
  120. package/dist/native/utils/geometry.d.ts.map +1 -0
  121. package/dist/native/utils/geometry.js +77 -0
  122. package/dist/native/utils/geometry.js.map +1 -0
  123. package/dist/native/utils/redactNative.d.ts +2 -0
  124. package/dist/native/utils/redactNative.d.ts.map +1 -0
  125. package/dist/native/utils/redactNative.js +7 -0
  126. package/dist/native/utils/redactNative.js.map +1 -0
  127. package/dist/native/utils/waitFor.d.ts +7 -0
  128. package/dist/native/utils/waitFor.d.ts.map +1 -0
  129. package/dist/native/utils/waitFor.js +17 -0
  130. package/dist/native/utils/waitFor.js.map +1 -0
  131. package/dist/sites/upwork-com/upwork-com.actor.d.ts +4 -1
  132. package/dist/sites/upwork-com/upwork-com.actor.d.ts.map +1 -1
  133. package/dist/sites/upwork-com/upwork-com.actor.js +30 -12
  134. package/dist/sites/upwork-com/upwork-com.actor.js.map +1 -1
  135. package/dist/sites/upwork-com/upwork-com.types.d.ts +3 -1
  136. package/dist/sites/upwork-com/upwork-com.types.d.ts.map +1 -1
  137. package/dist/sites/upwork-com/upwork-com.types.js.map +1 -1
  138. package/dist/sites/upwork-com/util/parseJobApplicationDetails.d.ts +70 -0
  139. package/dist/sites/upwork-com/util/parseJobApplicationDetails.d.ts.map +1 -0
  140. package/dist/sites/upwork-com/util/parseJobApplicationDetails.js +334 -0
  141. package/dist/sites/upwork-com/util/parseJobApplicationDetails.js.map +1 -0
  142. package/dist/sites/upwork-com/util/scrapeJobListing.d.ts +1 -0
  143. package/dist/sites/upwork-com/util/scrapeJobListing.d.ts.map +1 -1
  144. package/dist/sites/upwork-com/util/scrapeJobListing.js +4 -0
  145. package/dist/sites/upwork-com/util/scrapeJobListing.js.map +1 -1
  146. package/package.json +5 -1
  147. package/src/browser/RuntimeConfig.ts +57 -1
  148. package/src/core/ActorContext.ts +2 -0
  149. package/src/core/ActorRunner.ts +13 -1
  150. package/src/index.ts +2 -0
  151. package/src/native/CompositeNativeWindowDriver.ts +30 -0
  152. package/src/native/NativeActionRegistry.ts +114 -0
  153. package/src/native/NativeAutomation.ts +15 -0
  154. package/src/native/NativeCoordinateMapper.ts +258 -0
  155. package/src/native/NativeFileDialogService.ts +138 -0
  156. package/src/native/NativeImageFinder.ts +33 -0
  157. package/src/native/NativeKeyboard.ts +18 -0
  158. package/src/native/NativeMouse.ts +116 -0
  159. package/src/native/NativeWindowService.ts +229 -0
  160. package/src/native/UnsupportedNativeAutomation.ts +92 -0
  161. package/src/native/WindowMatcher.ts +31 -0
  162. package/src/native/drivers.ts +38 -0
  163. package/src/native/errors.ts +51 -0
  164. package/src/native/index.ts +12 -0
  165. package/src/native/macos/MacOSAccessibilityWindowDriver.ts +183 -0
  166. package/src/native/macos/MacOSAppleScriptClient.ts +182 -0
  167. package/src/native/macos/MacOSFileDialogAccessibilityStrategy.ts +11 -0
  168. package/src/native/macos/MacOSNativeAutomation.ts +86 -0
  169. package/src/native/nut/NutNativeImageFinder.ts +98 -0
  170. package/src/native/nut/NutNativeKeyboardDriver.ts +38 -0
  171. package/src/native/nut/NutNativeMouseDriver.ts +27 -0
  172. package/src/native/nut/NutNativeScreenDriver.ts +14 -0
  173. package/src/native/nut/NutNativeWindowDriver.ts +61 -0
  174. package/src/native/nut/loadNut.ts +86 -0
  175. package/src/native/types.ts +224 -0
  176. package/src/native/utils/appleScriptEscape.ts +11 -0
  177. package/src/native/utils/geometry.ts +88 -0
  178. package/src/native/utils/redactNative.ts +6 -0
  179. package/src/native/utils/waitFor.ts +25 -0
  180. package/src/sites/upwork-com/upwork-com.actor.ts +46 -15
  181. package/src/sites/upwork-com/upwork-com.types.ts +4 -1
  182. package/src/sites/upwork-com/util/parseJobApplicationDetails.ts +622 -0
  183. package/src/sites/upwork-com/util/scrapeJobListing.ts +4 -3
  184. package/tests/fixtures/makeContext.ts +7 -2
  185. package/tests/fixtures/native/FakeNativeAutomation.ts +138 -0
  186. package/tests/unit/browser/RuntimeConfig.native.test.ts +63 -0
  187. package/tests/unit/core/ActorRunner.native.test.ts +69 -0
  188. package/tests/unit/native/MacOSAppleScriptClient.test.ts +35 -0
  189. package/tests/unit/native/NativeActionRegistry.test.ts +34 -0
  190. package/tests/unit/native/NativeCoordinateMapper.test.ts +92 -0
  191. package/tests/unit/native/NativeFileDialogService.test.ts +91 -0
  192. package/tests/unit/native/NativeMouse.test.ts +91 -0
  193. package/tests/unit/native/NativeWindowService.test.ts +87 -0
  194. package/tests/unit/native/WindowMatcher.test.ts +32 -0
  195. package/tests/unit/native/appleScriptEscape.test.ts +9 -0
  196. package/tests/unit/sites/myvistage-com.login.test.ts +1 -1
  197. package/tests/unit/sites/myvistage-com.postComment.test.ts +0 -1
  198. package/tests/unit/sites/upwork-com.login.test.ts +1 -1
@@ -0,0 +1,622 @@
1
+ import type { ElementHandle, Page } from "puppeteer-core";
2
+
3
+ /* -------------------------------------------------------------------------------------------------
4
+ * Types
5
+ * ------------------------------------------------------------------------------------------------- */
6
+
7
+ export type UpworkProposalQuestionKind =
8
+ | "cover_letter"
9
+ | "custom_question";
10
+
11
+ export type UpworkProposalControlType =
12
+ | "textarea"
13
+ | "input"
14
+ | "select"
15
+ | "unknown";
16
+
17
+ export interface UpworkProposalQuestionSelectors {
18
+ container: string | null;
19
+ label: string | null;
20
+ control: string | null;
21
+ }
22
+
23
+ export interface UpworkProposalQuestion {
24
+ id: string;
25
+
26
+ kind: UpworkProposalQuestionKind;
27
+
28
+ /**
29
+ * The visible prompt shown to the freelancer.
30
+ *
31
+ * Examples:
32
+ * - "Cover Letter"
33
+ * - "Describe your recent experience with similar projects"
34
+ */
35
+ prompt: string;
36
+
37
+ /**
38
+ * For cover letter this is always 0.
39
+ * For custom questions this is their order under `.questions-area`.
40
+ */
41
+ index: number;
42
+
43
+ controlType: UpworkProposalControlType;
44
+
45
+ /**
46
+ * Best-effort CSS selectors for later filling.
47
+ * These are generated from stable page sections, not Vue data-v-* attrs.
48
+ */
49
+ selectors: UpworkProposalQuestionSelectors;
50
+
51
+ /**
52
+ * `true` for Cover Letter.
53
+ * `true` for custom questions only when the DOM gives us a required marker/error.
54
+ * `null` means not determinable from the current DOM.
55
+ */
56
+ required: boolean | null;
57
+
58
+ placeholder: string | null;
59
+
60
+ currentValue: string;
61
+
62
+ rows: number | null;
63
+
64
+ maxLength: number | null;
65
+
66
+ ariaLabelledBy: string | null;
67
+
68
+ errorMessages: string[];
69
+ }
70
+
71
+
72
+ export interface UpworkProposalQuestions {
73
+ hasAdditionalDetailsSection: boolean;
74
+
75
+ sourceUrl: string | null;
76
+
77
+ coverLetter: UpworkProposalQuestion | null;
78
+
79
+ customQuestions: UpworkProposalQuestion[];
80
+
81
+ allQuestions: UpworkProposalQuestion[];
82
+
83
+ counts: {
84
+ total: number;
85
+ custom: number;
86
+ hasCoverLetter: boolean;
87
+ };
88
+ }
89
+
90
+ /* -------------------------------------------------------------------------------------------------
91
+ * Browser-context parser
92
+ *
93
+ * IMPORTANT:
94
+ * This function is intentionally self-contained because Puppeteer serializes it
95
+ * into the browser context. Do not close over Node-side variables here.
96
+ * ------------------------------------------------------------------------------------------------- */
97
+
98
+ function parseProposalQuestionsInBrowser(
99
+ rootNode: ParentNode | null,
100
+ sourceUrl: string | null,
101
+ ): UpworkProposalQuestions {
102
+ const root: ParentNode = rootNode ?? document;
103
+
104
+ const cleanText = (input?: string | null): string => {
105
+ return (input ?? "")
106
+ .replace(/\u00a0/g, " ")
107
+ .replace(/\s+/g, " ")
108
+ .trim();
109
+ };
110
+
111
+ const toNumber = (input?: string | null): number | null => {
112
+ const match = (input ?? "").match(/\d+/);
113
+ return match ? Number(match[0]) : null;
114
+ };
115
+
116
+ const slugify = (input: string): string => {
117
+ const slug = input
118
+ .toLowerCase()
119
+ .normalize("NFKD")
120
+ .replace(/[^\w\s-]/g, "")
121
+ .replace(/\s+/g, "-")
122
+ .replace(/-+/g, "-")
123
+ .replace(/^-|-$/g, "")
124
+ .slice(0, 80);
125
+
126
+ return slug || "question";
127
+ };
128
+
129
+ const cssEscape = (input: string): string => {
130
+ const css = (globalThis as unknown as { CSS?: { escape?: (value: string) => string } }).CSS;
131
+
132
+ if (css?.escape) {
133
+ return css.escape(input);
134
+ }
135
+
136
+ return input.replace(/["\\]/g, "\\$&");
137
+ };
138
+
139
+ const findCardByHeading = (
140
+ headingText: string,
141
+ ): HTMLElement | null => {
142
+ const headings = Array.from(
143
+ root.querySelectorAll<HTMLElement>("h1, h2, h3, h4, h5"),
144
+ );
145
+
146
+ const heading = headings.find(
147
+ (el) => cleanText(el.textContent).toLowerCase() === headingText.toLowerCase(),
148
+ );
149
+
150
+ if (!heading) {
151
+ return null;
152
+ }
153
+
154
+ return (
155
+ heading.closest<HTMLElement>(".fe-proposal-additional-details") ??
156
+ heading.closest<HTMLElement>(".additional-details") ??
157
+ heading.closest<HTMLElement>(".air3-card") ??
158
+ heading.parentElement
159
+ );
160
+ };
161
+
162
+ const additionalDetails =
163
+ root.querySelector<HTMLElement>(".fe-proposal-additional-details") ??
164
+ root.querySelector<HTMLElement>(".additional-details") ??
165
+ findCardByHeading("Additional details");
166
+
167
+ const hasAdditionalDetailsSection = Boolean(additionalDetails);
168
+
169
+ const scope = additionalDetails ?? root;
170
+
171
+ const getErrorMessages = (container: Element): string[] => {
172
+ return Array.from(
173
+ container.querySelectorAll<HTMLElement>(
174
+ ".air3-form-message-error, [role='alert'], .error, .has-error",
175
+ ),
176
+ )
177
+ .map((el) => cleanText(el.textContent))
178
+ .filter(Boolean)
179
+ .filter((value, index, array) => array.indexOf(value) === index);
180
+ };
181
+
182
+ const getControlType = (
183
+ control: Element | null,
184
+ ): UpworkProposalControlType => {
185
+ if (!control) return "unknown";
186
+
187
+ const tag = control.tagName.toLowerCase();
188
+
189
+ if (tag === "textarea") return "textarea";
190
+ if (tag === "select") return "select";
191
+ if (tag === "input") return "input";
192
+
193
+ return "unknown";
194
+ };
195
+
196
+ const getControlValue = (control: Element | null): string => {
197
+ if (!control) return "";
198
+
199
+ if (
200
+ control instanceof HTMLTextAreaElement ||
201
+ control instanceof HTMLInputElement ||
202
+ control instanceof HTMLSelectElement
203
+ ) {
204
+ return control.value ?? "";
205
+ }
206
+
207
+ return "";
208
+ };
209
+
210
+ const getMaxLength = (control: Element | null): number | null => {
211
+ if (!control) return null;
212
+
213
+ const value = control.getAttribute("maxlength");
214
+
215
+ if (!value) return null;
216
+
217
+ const parsed = Number(value);
218
+
219
+ return Number.isFinite(parsed) ? parsed : null;
220
+ };
221
+
222
+ const getRows = (control: Element | null): number | null => {
223
+ if (!(control instanceof HTMLTextAreaElement)) {
224
+ return null;
225
+ }
226
+
227
+ return Number.isFinite(control.rows) ? control.rows : null;
228
+ };
229
+
230
+ const inferRequired = (
231
+ kind: UpworkProposalQuestionKind,
232
+ container: Element,
233
+ control: Element | null,
234
+ errors: string[],
235
+ ): boolean | null => {
236
+ if (kind === "cover_letter") {
237
+ return true;
238
+ }
239
+
240
+ if (control?.hasAttribute("required")) {
241
+ return true;
242
+ }
243
+
244
+ const text = cleanText(container.textContent).toLowerCase();
245
+ const errorText = errors.join(" ").toLowerCase();
246
+
247
+ if (container.classList.contains("has-error") && /required/.test(errorText)) {
248
+ return true;
249
+ }
250
+
251
+ if (/\brequired\b/.test(errorText)) {
252
+ return true;
253
+ }
254
+
255
+ if (/\brequired\b/.test(text)) {
256
+ return true;
257
+ }
258
+
259
+ return null;
260
+ };
261
+
262
+ const buildQuestion = (
263
+ params: {
264
+ kind: UpworkProposalQuestionKind;
265
+ index: number;
266
+ container: Element;
267
+ baseSelector: string | null;
268
+ },
269
+ ): UpworkProposalQuestion | null => {
270
+ const label = params.container.querySelector<HTMLElement>("label.label, label");
271
+
272
+ const control = params.container.querySelector<
273
+ HTMLTextAreaElement | HTMLInputElement | HTMLSelectElement
274
+ >(
275
+ [
276
+ "textarea",
277
+ "input:not([type='hidden']):not([type='file'])",
278
+ "select",
279
+ ].join(", "),
280
+ );
281
+
282
+ const prompt = cleanText(label?.textContent);
283
+
284
+ if (!prompt || !control) {
285
+ return null;
286
+ }
287
+
288
+ const controlType = getControlType(control);
289
+ const controlTag = control.tagName.toLowerCase();
290
+ const errors = getErrorMessages(params.container);
291
+
292
+ const ariaLabelledBy =
293
+ control.getAttribute("aria-labelledby") ||
294
+ control.getAttribute("aria-label") ||
295
+ null;
296
+
297
+ const id =
298
+ params.kind === "cover_letter"
299
+ ? "cover-letter"
300
+ : `custom-question-${params.index + 1}-${slugify(prompt)}`;
301
+
302
+ let containerSelector: string | null = null;
303
+ let labelSelector: string | null = null;
304
+ let controlSelector: string | null = null;
305
+
306
+ if (params.kind === "cover_letter") {
307
+ containerSelector =
308
+ ".fe-proposal-additional-details .cover-letter-area .form-group";
309
+
310
+ labelSelector =
311
+ ".fe-proposal-additional-details .cover-letter-area label";
312
+
313
+ controlSelector =
314
+ ".fe-proposal-additional-details .cover-letter-area textarea";
315
+ } else if (params.baseSelector) {
316
+ const nth = params.index + 1;
317
+
318
+ containerSelector =
319
+ `${params.baseSelector} .form-group:nth-of-type(${nth})`;
320
+
321
+ labelSelector =
322
+ `${containerSelector} label`;
323
+
324
+ controlSelector =
325
+ `${containerSelector} ${controlTag}`;
326
+ }
327
+
328
+ if (!controlSelector && control.id) {
329
+ controlSelector = `#${cssEscape(control.id)}`;
330
+ }
331
+
332
+ return {
333
+ id,
334
+
335
+ kind: params.kind,
336
+
337
+ prompt,
338
+
339
+ index: params.index,
340
+
341
+ controlType,
342
+
343
+ selectors: {
344
+ container: containerSelector,
345
+ label: labelSelector,
346
+ control: controlSelector,
347
+ },
348
+
349
+ required: inferRequired(params.kind, params.container, control, errors),
350
+
351
+ placeholder: control.getAttribute("placeholder") || null,
352
+
353
+ currentValue: getControlValue(control),
354
+
355
+ rows: getRows(control),
356
+
357
+ maxLength: getMaxLength(control),
358
+
359
+ ariaLabelledBy,
360
+
361
+ errorMessages: errors,
362
+ };
363
+ };
364
+
365
+ /* -----------------------------------------------------------------------------------------------
366
+ * Cover Letter
367
+ * --------------------------------------------------------------------------------------------- */
368
+
369
+
370
+ const coverLetterContainer =
371
+ scope.querySelector<HTMLElement>(".cover-letter-area .form-group") ??
372
+ Array.from(scope.querySelectorAll<HTMLElement>(".form-group")).find((el) =>
373
+ cleanText(el.querySelector("label")?.textContent).toLowerCase() === "cover letter",
374
+ ) ??
375
+ null;
376
+
377
+
378
+ const coverLetter = coverLetterContainer
379
+ ? buildQuestion({
380
+ kind: "cover_letter",
381
+ index: 0,
382
+ container: coverLetterContainer,
383
+ baseSelector: ".fe-proposal-additional-details .cover-letter-area",
384
+ })
385
+ : null;
386
+
387
+ /* -----------------------------------------------------------------------------------------------
388
+ * Custom Job Questions
389
+ * --------------------------------------------------------------------------------------------- */
390
+
391
+
392
+ const customQuestionArea =
393
+ scope.querySelector<HTMLElement>(".fe-proposal-job-questions.questions-area") ??
394
+ scope.querySelector<HTMLElement>(".questions-area");
395
+
396
+
397
+ const customQuestionContainers = customQuestionArea
398
+ ? Array.from(customQuestionArea.querySelectorAll<HTMLElement>(".form-group"))
399
+ .filter((el) =>
400
+ Boolean(
401
+ el.querySelector(
402
+ [
403
+ "textarea",
404
+ "input:not([type='hidden']):not([type='file'])",
405
+ "select",
406
+ ].join(", "),
407
+ ),
408
+ ),
409
+ )
410
+ .filter((el) => {
411
+ const labelText = cleanText(el.querySelector("label")?.textContent).toLowerCase();
412
+
413
+ return (
414
+ labelText !== "cover letter" &&
415
+ labelText !== "attachments"
416
+ );
417
+ })
418
+ : [];
419
+
420
+
421
+ const customQuestions = customQuestionContainers
422
+ .map((container, index) =>
423
+ buildQuestion({
424
+ kind: "custom_question",
425
+ index,
426
+ container,
427
+ baseSelector:
428
+ ".fe-proposal-additional-details .fe-proposal-job-questions.questions-area",
429
+ }),
430
+ )
431
+ .filter((question): question is UpworkProposalQuestion => Boolean(question));
432
+
433
+ /* -----------------------------------------------------------------------------------------------
434
+ * Attachments metadata
435
+ * --------------------------------------------------------------------------------------------- */
436
+
437
+
438
+ const attachmentsArea =
439
+ scope.querySelector<HTMLElement>(".attachments-area") ??
440
+ Array.from(scope.querySelectorAll<HTMLElement>(".form-group")).find((el) =>
441
+ cleanText(el.querySelector("label")?.textContent).toLowerCase() === "attachments",
442
+ ) ??
443
+ null;
444
+
445
+
446
+ const attachmentsText = cleanText(attachmentsArea?.textContent);
447
+
448
+
449
+ const fileInput =
450
+ attachmentsArea?.querySelector<HTMLInputElement>("input[type='file']") ??
451
+ null;
452
+
453
+
454
+ const rejectTypesHolder =
455
+ attachmentsArea?.querySelector<HTMLElement>("[reject_types]") ??
456
+ null;
457
+
458
+
459
+ const rejectedExtensions = cleanText(
460
+ rejectTypesHolder?.getAttribute("reject_types") ?? "",
461
+ )
462
+ .split(",")
463
+ .map((value) => value.trim().replace(/^\./, ""))
464
+ .filter(Boolean);
465
+
466
+ const allQuestions = [
467
+ ...(coverLetter ? [coverLetter] : []),
468
+ ...customQuestions,
469
+ ];
470
+
471
+ return {
472
+ hasAdditionalDetailsSection,
473
+
474
+ sourceUrl,
475
+
476
+ coverLetter,
477
+
478
+ customQuestions,
479
+
480
+ allQuestions,
481
+
482
+
483
+
484
+ counts: {
485
+ total: allQuestions.length,
486
+ custom: customQuestions.length,
487
+ hasCoverLetter: Boolean(coverLetter),
488
+ },
489
+ };
490
+ }
491
+
492
+ /* -------------------------------------------------------------------------------------------------
493
+ * Puppeteer API
494
+ * ------------------------------------------------------------------------------------------------- */
495
+
496
+ export async function parseUpworkProposalQuestions(
497
+ page: Page,
498
+ options?: {
499
+ timeoutMs?: number;
500
+ },
501
+ ): Promise<UpworkProposalQuestions> {
502
+ const timeoutMs = options?.timeoutMs ?? 15_000;
503
+
504
+ await page
505
+ .waitForSelector(
506
+ [
507
+ ".fe-proposal-additional-details",
508
+ ".additional-details",
509
+ ".cover-letter-area",
510
+ ".questions-area",
511
+ ].join(", "),
512
+ {
513
+ timeout: timeoutMs,
514
+ },
515
+ )
516
+ .catch(() => {
517
+ // Some proposal pages may render only a cover letter or may lazy-render the
518
+ // additional details section. We still try to parse the current DOM.
519
+ });
520
+
521
+ return page.evaluate(
522
+ parseProposalQuestionsInBrowser,
523
+ null,
524
+ page.url(),
525
+ );
526
+ }
527
+
528
+ export async function parseUpworkProposalQuestionsFromRoot(
529
+ root: ElementHandle<Element>,
530
+ sourceUrl: string | null = null,
531
+ ): Promise<UpworkProposalQuestions> {
532
+ return root.evaluate(
533
+ parseProposalQuestionsInBrowser,
534
+ sourceUrl,
535
+ );
536
+ }
537
+
538
+ /* -------------------------------------------------------------------------------------------------
539
+ * Optional helper: fill answers after parsing
540
+ * ------------------------------------------------------------------------------------------------- */
541
+
542
+ export interface UpworkProposalQuestionAnswers {
543
+ coverLetter?: string;
544
+
545
+ /**
546
+ * You can key custom answers by:
547
+ * - generated question id
548
+ * - exact prompt text
549
+ */
550
+ customQuestions?: Record<string, string>;
551
+ }
552
+
553
+ export async function fillUpworkProposalQuestions(
554
+ page: Page,
555
+ answers: UpworkProposalQuestionAnswers,
556
+ ): Promise<void> {
557
+ const parsed = await parseUpworkProposalQuestions(page);
558
+
559
+ if (answers.coverLetter && parsed.coverLetter?.selectors.control) {
560
+ await page.focus(parsed.coverLetter.selectors.control);
561
+ await page.keyboard.down(process.platform === "darwin" ? "Meta" : "Control");
562
+ await page.keyboard.press("A");
563
+ await page.keyboard.up(process.platform === "darwin" ? "Meta" : "Control");
564
+ await page.type(parsed.coverLetter.selectors.control, answers.coverLetter);
565
+ }
566
+
567
+ const customAnswers = answers.customQuestions ?? {};
568
+
569
+ for (const question of parsed.customQuestions) {
570
+ const answer =
571
+ customAnswers[question.id] ??
572
+ customAnswers[question.prompt];
573
+
574
+ if (!answer || !question.selectors.control) {
575
+ continue;
576
+ }
577
+
578
+ await page.focus(question.selectors.control);
579
+ await page.keyboard.down(process.platform === "darwin" ? "Meta" : "Control");
580
+ await page.keyboard.press("A");
581
+ await page.keyboard.up(process.platform === "darwin" ? "Meta" : "Control");
582
+ await page.type(question.selectors.control, answer);
583
+ }
584
+ }
585
+
586
+ /* -------------------------------------------------------------------------------------------------
587
+ * Example
588
+ * ------------------------------------------------------------------------------------------------- */
589
+
590
+ /*
591
+ import puppeteer from "puppeteer";
592
+
593
+ async function main(): Promise<void> {
594
+ const browser = await puppeteer.launch({
595
+ headless: false,
596
+ });
597
+
598
+ const page = await browser.newPage();
599
+
600
+ await page.goto("https://www.upwork.com/ab/proposals/job/~YOUR_JOB_ID/apply/", {
601
+ waitUntil: "networkidle2",
602
+ });
603
+
604
+ const questions = await parseUpworkProposalQuestions(page);
605
+
606
+ console.dir(questions, {
607
+ depth: null,
608
+ });
609
+
610
+ await fillUpworkProposalQuestions(page, {
611
+ coverLetter: "Hi, I have relevant experience...",
612
+ customQuestions: {
613
+ "Describe your recent experience with similar projects":
614
+ "Recently I led a similar product and engineering effort...",
615
+ },
616
+ });
617
+
618
+ await browser.close();
619
+ }
620
+
621
+ void main();
622
+ */
@@ -14,7 +14,7 @@ export const UpworkJobListingSchema = z.object({
14
14
 
15
15
  title: z.string(),
16
16
  url: z.string(),
17
-
17
+ alreadyApplied: z.boolean(),
18
18
  postedAtText: z.string().nullable(),
19
19
  proposalsText: z.string().nullable(),
20
20
 
@@ -155,6 +155,7 @@ export async function parseUpworkJobListing(
155
155
  node.querySelectorAll('[data-test="JobInfoClient"] li'),
156
156
  ).map((li) => li.textContent?.trim() ?? "");
157
157
 
158
+ const alreadyApplied = !!node.querySelector('[data-test="JobBadgeApplied"]');
158
159
  const hourlyItem =
159
160
  jobInfoItems.find((x) => x.toLowerCase().includes("hourly")) ?? null;
160
161
 
@@ -191,7 +192,7 @@ export async function parseUpworkJobListing(
191
192
  node.getAttribute("data-ev-job-uid") ??
192
193
  node.getAttribute("data-test-key") ??
193
194
  "",
194
-
195
+ alreadyApplied,
195
196
  title: titleAnchor?.textContent?.trim() ?? "",
196
197
 
197
198
  url: titleAnchor?.href ?? "",
@@ -237,7 +238,7 @@ export async function parseUpworkJobListing(
237
238
 
238
239
  const parsed: UpworkJobListing = {
239
240
  id: cleanText(extracted.id),
240
-
241
+ alreadyApplied: extracted.alreadyApplied,
241
242
  title: cleanText(extracted.title),
242
243
 
243
244
  url: cleanText(extracted.url),
@@ -13,6 +13,7 @@ import { PuppeteerPageAdapter } from '../../src/interaction/PageAdapter.js';
13
13
  import { MemoryLogger } from '../../src/logging/MemoryLogger.js';
14
14
  import { FakeHumanInteractor } from './FakeCursor.js';
15
15
  import { FakeBrowser, FakePage } from './FakePage.js';
16
+ import { createFakeNativeAutomation } from './native/FakeNativeAutomation.js';
16
17
 
17
18
  export interface TestContextParts {
18
19
  context: ActorContext;
@@ -20,6 +21,7 @@ export interface TestContextParts {
20
21
  fakeInteractor: FakeHumanInteractor;
21
22
  logger: MemoryLogger;
22
23
  config: RuntimeConfig;
24
+ native: ReturnType<typeof createFakeNativeAutomation>;
23
25
  }
24
26
 
25
27
  export function makeContext(overrides: Partial<{
@@ -28,6 +30,7 @@ export function makeContext(overrides: Partial<{
28
30
  actorId: string;
29
31
  baseUrl: string;
30
32
  auth: AuthController;
33
+ native: ReturnType<typeof createFakeNativeAutomation>;
31
34
  }> = {}): TestContextParts {
32
35
  const fakePage = overrides.page ?? new FakePage();
33
36
  const fakeInteractor = overrides.interactor instanceof FakeHumanInteractor
@@ -52,6 +55,7 @@ export function makeContext(overrides: Partial<{
52
55
  const extract = new Extractor(page);
53
56
  const pagination = new Pagination(page, cursor);
54
57
  const logger = new MemoryLogger();
58
+ const native = overrides.native ?? createFakeNativeAutomation(logger);
55
59
 
56
60
  const auth: AuthController = overrides.auth ?? {
57
61
  isLoggedIn: async () => false,
@@ -69,8 +73,9 @@ export function makeContext(overrides: Partial<{
69
73
  extract,
70
74
  pagination,
71
75
  auth,
72
- logger
76
+ logger,
77
+ native
73
78
  };
74
79
 
75
- return { context, fakePage, fakeInteractor, logger, config };
80
+ return { context, fakePage, fakeInteractor, logger, config, native };
76
81
  }