googleformsubmitter 1.0.0
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/.data/form-cache-1FAIpQLSc1aiEAhxWCMSNjZ1asGDtXV_mT8vTiJRquiqfdjsoMkKT9tw.json +16 -0
- package/.env.example +4 -0
- package/CLAUDE.md +106 -0
- package/GoogleFormSubmitter.ts +319 -0
- package/README.md +115 -0
- package/bun.lock +328 -0
- package/create-form.ts +410 -0
- package/index.ts +2 -0
- package/package.json +26 -0
- package/test.ts +139 -0
- package/tsconfig.json +30 -0
- package/types.ts +18 -0
package/create-form.ts
ADDED
|
@@ -0,0 +1,410 @@
|
|
|
1
|
+
import fs from 'fs/promises';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import process from 'process';
|
|
4
|
+
import { google } from 'googleapis';
|
|
5
|
+
import { authenticate } from '@google-cloud/local-auth';
|
|
6
|
+
|
|
7
|
+
// Helper to check if file exists
|
|
8
|
+
async function fileExists(filePath: string): Promise<boolean> {
|
|
9
|
+
try {
|
|
10
|
+
await fs.access(filePath);
|
|
11
|
+
return true;
|
|
12
|
+
} catch {
|
|
13
|
+
return false;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
async function resolvePaths() {
|
|
18
|
+
let credentialsPath = path.join(process.cwd(), '.data', 'credentials.json');
|
|
19
|
+
let tokenPath = path.join(process.cwd(), '.data', 'forms-auth.json');
|
|
20
|
+
|
|
21
|
+
// Fallback to parent directory if not found in current directory
|
|
22
|
+
if (!(await fileExists(credentialsPath))) {
|
|
23
|
+
const parentCreds = path.join(process.cwd(), '..', '.data', 'credentials.json');
|
|
24
|
+
if (await fileExists(parentCreds)) {
|
|
25
|
+
credentialsPath = parentCreds;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
if (!(await fileExists(tokenPath))) {
|
|
30
|
+
const parentToken = path.join(process.cwd(), '..', '.data', 'forms-auth.json');
|
|
31
|
+
if (await fileExists(parentToken)) {
|
|
32
|
+
tokenPath = parentToken;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return { credentialsPath, tokenPath };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const SCOPES = [
|
|
40
|
+
'https://www.googleapis.com/auth/forms.body',
|
|
41
|
+
'https://www.googleapis.com/auth/drive'
|
|
42
|
+
];
|
|
43
|
+
|
|
44
|
+
async function loadSavedCredentialsIfExist(tokenPath: string) {
|
|
45
|
+
try {
|
|
46
|
+
const content = await fs.readFile(tokenPath, 'utf-8');
|
|
47
|
+
const credentials = JSON.parse(content);
|
|
48
|
+
|
|
49
|
+
const oauth2Client = new google.auth.OAuth2(
|
|
50
|
+
credentials.client_id,
|
|
51
|
+
credentials.client_secret
|
|
52
|
+
);
|
|
53
|
+
oauth2Client.setCredentials({ refresh_token: credentials.refresh_token });
|
|
54
|
+
return oauth2Client;
|
|
55
|
+
} catch (err) {
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async function saveCredentials(refreshToken: string, credentialsPath: string, tokenPath: string): Promise<void> {
|
|
61
|
+
const content = await fs.readFile(credentialsPath, 'utf-8');
|
|
62
|
+
const keys = JSON.parse(content);
|
|
63
|
+
const key = keys.installed || keys.web;
|
|
64
|
+
const payload = JSON.stringify({
|
|
65
|
+
type: 'authorized_user',
|
|
66
|
+
client_id: key.client_id,
|
|
67
|
+
client_secret: key.client_secret,
|
|
68
|
+
refresh_token: refreshToken,
|
|
69
|
+
});
|
|
70
|
+
await fs.mkdir(path.dirname(tokenPath), { recursive: true });
|
|
71
|
+
await fs.writeFile(tokenPath, payload);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async function authorize(credentialsPath: string, tokenPath: string) {
|
|
75
|
+
const saved = await loadSavedCredentialsIfExist(tokenPath);
|
|
76
|
+
if (saved) return saved;
|
|
77
|
+
|
|
78
|
+
console.log('Triggering new authentication flow for Google Forms & Drive...');
|
|
79
|
+
const client = await authenticate({
|
|
80
|
+
scopes: SCOPES,
|
|
81
|
+
keyfilePath: credentialsPath,
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
const refreshToken = client.credentials.refresh_token;
|
|
85
|
+
if (refreshToken) {
|
|
86
|
+
await saveCredentials(refreshToken, credentialsPath, tokenPath);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const content = await fs.readFile(credentialsPath, 'utf-8');
|
|
90
|
+
const keys = JSON.parse(content);
|
|
91
|
+
const key = keys.installed || keys.web;
|
|
92
|
+
const oauth2Client = new google.auth.OAuth2(
|
|
93
|
+
key.client_id,
|
|
94
|
+
key.client_secret,
|
|
95
|
+
key.redirect_uris?.[0]
|
|
96
|
+
);
|
|
97
|
+
oauth2Client.setCredentials(client.credentials);
|
|
98
|
+
return oauth2Client;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
async function main() {
|
|
102
|
+
const { credentialsPath, tokenPath } = await resolvePaths();
|
|
103
|
+
|
|
104
|
+
await fs.access(credentialsPath).catch(() => {
|
|
105
|
+
console.error(`❌ Error: Credentials file not found at ${credentialsPath}`);
|
|
106
|
+
console.log('Please place your Google Cloud credentials.json in the .data directory.');
|
|
107
|
+
process.exit(1);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
const auth = await authorize(credentialsPath, tokenPath);
|
|
111
|
+
const forms = google.forms({ version: 'v1', auth });
|
|
112
|
+
|
|
113
|
+
console.log('Creating new Google Form...');
|
|
114
|
+
const formRes = await forms.forms.create({
|
|
115
|
+
requestBody: {
|
|
116
|
+
info: {
|
|
117
|
+
title: "Анкета для отправки кандидата на рассмотрение (Копия)"
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
const formId = formRes.data.formId!;
|
|
123
|
+
const responderUri = formRes.data.responderUri!;
|
|
124
|
+
console.log(`Created Form ID: ${formId}`);
|
|
125
|
+
|
|
126
|
+
console.log('Adding questions to the form...');
|
|
127
|
+
|
|
128
|
+
const requests = [
|
|
129
|
+
// 1. Update form description
|
|
130
|
+
{
|
|
131
|
+
updateFormInfo: {
|
|
132
|
+
info: {
|
|
133
|
+
description: "Заполните, пожалуйста, информацию по направляемому кандидату"
|
|
134
|
+
},
|
|
135
|
+
updateMask: "description"
|
|
136
|
+
}
|
|
137
|
+
},
|
|
138
|
+
// 2. Add "Номер запроса" (Required, Short Text)
|
|
139
|
+
{
|
|
140
|
+
createItem: {
|
|
141
|
+
item: {
|
|
142
|
+
title: "Номер запроса",
|
|
143
|
+
questionItem: {
|
|
144
|
+
question: {
|
|
145
|
+
required: true,
|
|
146
|
+
textQuestion: {}
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
},
|
|
150
|
+
location: { index: 0 }
|
|
151
|
+
}
|
|
152
|
+
},
|
|
153
|
+
// 3. Add "ФИО (полностью)" (Required, Short Text)
|
|
154
|
+
{
|
|
155
|
+
createItem: {
|
|
156
|
+
item: {
|
|
157
|
+
title: "ФИО (полностью)",
|
|
158
|
+
questionItem: {
|
|
159
|
+
question: {
|
|
160
|
+
required: true,
|
|
161
|
+
textQuestion: {}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
},
|
|
165
|
+
location: { index: 1 }
|
|
166
|
+
}
|
|
167
|
+
},
|
|
168
|
+
// 4. Add "Дата рождения" (Required, Date)
|
|
169
|
+
{
|
|
170
|
+
createItem: {
|
|
171
|
+
item: {
|
|
172
|
+
title: "Дата рождения",
|
|
173
|
+
questionItem: {
|
|
174
|
+
question: {
|
|
175
|
+
required: true,
|
|
176
|
+
dateQuestion: {
|
|
177
|
+
includeYear: true,
|
|
178
|
+
includeTime: false
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
},
|
|
183
|
+
location: { index: 2 }
|
|
184
|
+
}
|
|
185
|
+
},
|
|
186
|
+
// 5. Add "Грейд" (Required, Dropdown)
|
|
187
|
+
{
|
|
188
|
+
createItem: {
|
|
189
|
+
item: {
|
|
190
|
+
title: "Грейд",
|
|
191
|
+
questionItem: {
|
|
192
|
+
question: {
|
|
193
|
+
required: true,
|
|
194
|
+
choiceQuestion: {
|
|
195
|
+
type: "DROP_DOWN",
|
|
196
|
+
options: [
|
|
197
|
+
{ value: "Junior" },
|
|
198
|
+
{ value: "Junior+" },
|
|
199
|
+
{ value: "Middle" },
|
|
200
|
+
{ value: "Middle+" },
|
|
201
|
+
{ value: "Senior" },
|
|
202
|
+
{ value: "Senior+" },
|
|
203
|
+
{ value: "Team Lead" }
|
|
204
|
+
]
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
},
|
|
209
|
+
location: { index: 3 }
|
|
210
|
+
}
|
|
211
|
+
},
|
|
212
|
+
// 6. Add "Локация (страна, город)" (Required, Short Text)
|
|
213
|
+
{
|
|
214
|
+
createItem: {
|
|
215
|
+
item: {
|
|
216
|
+
title: "Локация (страна, город)",
|
|
217
|
+
questionItem: {
|
|
218
|
+
question: {
|
|
219
|
+
required: true,
|
|
220
|
+
textQuestion: {}
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
},
|
|
224
|
+
location: { index: 4 }
|
|
225
|
+
}
|
|
226
|
+
},
|
|
227
|
+
// 7. Add "Рейт (руб./ч) без НДС " (Required, Short Text)
|
|
228
|
+
{
|
|
229
|
+
createItem: {
|
|
230
|
+
item: {
|
|
231
|
+
title: "Рейт (руб./ч) без НДС ",
|
|
232
|
+
questionItem: {
|
|
233
|
+
question: {
|
|
234
|
+
required: true,
|
|
235
|
+
textQuestion: {}
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
},
|
|
239
|
+
location: { index: 5 }
|
|
240
|
+
}
|
|
241
|
+
},
|
|
242
|
+
// 8. Add "Какой у вас НДС: " (Required, Multiple Choice)
|
|
243
|
+
{
|
|
244
|
+
createItem: {
|
|
245
|
+
item: {
|
|
246
|
+
title: "Какой у вас НДС: ",
|
|
247
|
+
questionItem: {
|
|
248
|
+
question: {
|
|
249
|
+
required: true,
|
|
250
|
+
choiceQuestion: {
|
|
251
|
+
type: "RADIO",
|
|
252
|
+
options: [
|
|
253
|
+
{ value: "5%" },
|
|
254
|
+
{ value: "7%" },
|
|
255
|
+
{ value: "20%" },
|
|
256
|
+
{ value: "22%" },
|
|
257
|
+
{ value: "Нет" }
|
|
258
|
+
]
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
},
|
|
263
|
+
location: { index: 6 }
|
|
264
|
+
}
|
|
265
|
+
},
|
|
266
|
+
// 9. Add "Когда кандидат сможет выйти на проект" (Required, Paragraph)
|
|
267
|
+
{
|
|
268
|
+
createItem: {
|
|
269
|
+
item: {
|
|
270
|
+
title: "Когда кандидат сможет выйти на проект",
|
|
271
|
+
questionItem: {
|
|
272
|
+
question: {
|
|
273
|
+
required: true,
|
|
274
|
+
textQuestion: {
|
|
275
|
+
paragraph: true
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
},
|
|
280
|
+
location: { index: 7 }
|
|
281
|
+
}
|
|
282
|
+
},
|
|
283
|
+
// 10. Add "Ближайший планируемый отпуск" (Required, Short Text)
|
|
284
|
+
{
|
|
285
|
+
createItem: {
|
|
286
|
+
item: {
|
|
287
|
+
title: "Ближайший планируемый отпуск",
|
|
288
|
+
questionItem: {
|
|
289
|
+
question: {
|
|
290
|
+
required: true,
|
|
291
|
+
textQuestion: {}
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
},
|
|
295
|
+
location: { index: 8 }
|
|
296
|
+
}
|
|
297
|
+
},
|
|
298
|
+
// 11. Add "Находится ли специалист в штате? " (Required, Multiple Choice)
|
|
299
|
+
{
|
|
300
|
+
createItem: {
|
|
301
|
+
item: {
|
|
302
|
+
title: "Находится ли специалист в штате? ",
|
|
303
|
+
description: "⚠️ [ВНИМАНИЕ] В веб-редакторе формы обязательно нажмите кнопку \"Добавить вариант 'Другое'\" (Add 'Other') для этого вопроса.",
|
|
304
|
+
questionItem: {
|
|
305
|
+
question: {
|
|
306
|
+
required: true,
|
|
307
|
+
choiceQuestion: {
|
|
308
|
+
type: "RADIO",
|
|
309
|
+
options: [
|
|
310
|
+
{ value: "Да" }
|
|
311
|
+
]
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
},
|
|
316
|
+
location: { index: 9 }
|
|
317
|
+
}
|
|
318
|
+
},
|
|
319
|
+
// 12. Add PLACEHOLDER for CV (файл)
|
|
320
|
+
{
|
|
321
|
+
createItem: {
|
|
322
|
+
item: {
|
|
323
|
+
title: "CV (файл) ",
|
|
324
|
+
description: "⚠️ [ВНИМАНИЕ] Google Forms API запрещает программное создание полей 'Загрузка файла'. Пожалуйста, перейдите в веб-интерфейс редактирования формы по ссылке ниже и вручную измените тип этого вопроса с 'Текст (строка)' на 'Загрузка файлов' (File Upload). Назовите вопрос именно 'CV (файл) ', сделайте его обязательным и укажите разрешённые типы: только DOC/DOCX, размер до 10 МБ.",
|
|
325
|
+
questionItem: {
|
|
326
|
+
question: {
|
|
327
|
+
required: true,
|
|
328
|
+
textQuestion: {}
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
},
|
|
332
|
+
location: { index: 10 }
|
|
333
|
+
}
|
|
334
|
+
},
|
|
335
|
+
// 13. Add "Название вашей компании и контакты для связи" (Required, Short Text)
|
|
336
|
+
{
|
|
337
|
+
createItem: {
|
|
338
|
+
item: {
|
|
339
|
+
title: "Название вашей компании и контакты для связи",
|
|
340
|
+
description: "Пример: 65apps, ТГ - @Elizaveta_Kalinina",
|
|
341
|
+
questionItem: {
|
|
342
|
+
question: {
|
|
343
|
+
required: true,
|
|
344
|
+
textQuestion: {}
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
},
|
|
348
|
+
location: { index: 11 }
|
|
349
|
+
}
|
|
350
|
+
},
|
|
351
|
+
// 14. Add "Чек-лист по требованиям " (Required, Paragraph)
|
|
352
|
+
{
|
|
353
|
+
createItem: {
|
|
354
|
+
item: {
|
|
355
|
+
title: "Чек-лист по требованиям ",
|
|
356
|
+
questionItem: {
|
|
357
|
+
question: {
|
|
358
|
+
required: true,
|
|
359
|
+
textQuestion: {
|
|
360
|
+
paragraph: true
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
},
|
|
365
|
+
location: { index: 12 }
|
|
366
|
+
}
|
|
367
|
+
},
|
|
368
|
+
// 15. Add "Комментарий" (Optional, Paragraph)
|
|
369
|
+
{
|
|
370
|
+
createItem: {
|
|
371
|
+
item: {
|
|
372
|
+
title: "Комментарий",
|
|
373
|
+
questionItem: {
|
|
374
|
+
question: {
|
|
375
|
+
required: false,
|
|
376
|
+
textQuestion: {
|
|
377
|
+
paragraph: true
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
},
|
|
382
|
+
location: { index: 13 }
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
];
|
|
386
|
+
|
|
387
|
+
await forms.forms.batchUpdate({
|
|
388
|
+
formId,
|
|
389
|
+
requestBody: {
|
|
390
|
+
requests
|
|
391
|
+
}
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
console.log('\n🎉 Google Form has been successfully created!');
|
|
395
|
+
console.log(`🔗 Link to edit (Google Forms Owner Web UI):`);
|
|
396
|
+
console.log(` https://docs.google.com/forms/d/${formId}/edit`);
|
|
397
|
+
console.log(`🔗 Link to fill (Responder Web UI):`);
|
|
398
|
+
console.log(` ${responderUri}`);
|
|
399
|
+
console.log('\n⚠️ IMPORTANT NOTES:');
|
|
400
|
+
console.log(' 1. Since Google Forms API forbids programmatic creation of File Upload fields,');
|
|
401
|
+
console.log(' the question "CV (файл) " was created as a standard text field.');
|
|
402
|
+
console.log(' You must open the edit link above and manually change the type of "CV (файл) " to "File Upload" (Загрузка файлов).');
|
|
403
|
+
console.log(' 2. For the question "Находится ли специалист в штате?", you must click "Добавить вариант \'Другое\'" (Add \'Other\')');
|
|
404
|
+
console.log(' in the edit UI so custom answers like "С рынка, на предоффере" can be submitted.');
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
main().catch((err) => {
|
|
408
|
+
console.error('❌ Failed to create Google Form:', err);
|
|
409
|
+
process.exit(1);
|
|
410
|
+
});
|
package/index.ts
ADDED
package/package.json
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "googleformsubmitter",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"module": "index.ts",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"scripts": {
|
|
7
|
+
"test": "bun run ./test.ts",
|
|
8
|
+
"lint": "oxlint",
|
|
9
|
+
"validate": "tsgo --noEmit",
|
|
10
|
+
"create-form": "bun run ./create-form.ts"
|
|
11
|
+
},
|
|
12
|
+
"devDependencies": {
|
|
13
|
+
"@types/bun": "latest",
|
|
14
|
+
"@typescript/native-preview": "^7.0.0-dev.20260706.1",
|
|
15
|
+
"oxlint": "^1.72.0"
|
|
16
|
+
},
|
|
17
|
+
"peerDependencies": {
|
|
18
|
+
"typescript": "^5"
|
|
19
|
+
},
|
|
20
|
+
"dependencies": {
|
|
21
|
+
"@google-cloud/local-auth": "^3.0.1",
|
|
22
|
+
"ajv": "^8.20.0",
|
|
23
|
+
"googleapis": "^173.0.0",
|
|
24
|
+
"playwright": "^1.61.1"
|
|
25
|
+
}
|
|
26
|
+
}
|
package/test.ts
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import { GoogleFormSubmitter } from './GoogleFormSubmitter';
|
|
2
|
+
import type { FormSchemaMapping } from './types';
|
|
3
|
+
import fs from 'fs/promises';
|
|
4
|
+
import path from 'path';
|
|
5
|
+
|
|
6
|
+
// 1. Describe the Form structure using JSON Schema (Standard specification)
|
|
7
|
+
const jsonSchema = {
|
|
8
|
+
type: 'object',
|
|
9
|
+
properties: {
|
|
10
|
+
requestId: { type: 'string' },
|
|
11
|
+
fullName: { type: 'string' },
|
|
12
|
+
birthDate: { type: 'string', pattern: '^\\d{4}-\\d{2}-\\d{2}$' },
|
|
13
|
+
grade: { type: 'string', enum: ['Junior', 'Junior+', 'Middle', 'Middle+', 'Senior', 'Senior+', 'Team Lead'] },
|
|
14
|
+
location: { type: 'string' },
|
|
15
|
+
rate: { type: 'string' },
|
|
16
|
+
nds: { type: 'string', enum: ['5%', '7%', '20%', '22%', 'Нет'] },
|
|
17
|
+
exitDate: { type: 'string' },
|
|
18
|
+
vacation: { type: 'string' },
|
|
19
|
+
inStaff: { type: 'string' },
|
|
20
|
+
cvFile: {
|
|
21
|
+
type: 'object',
|
|
22
|
+
properties: {
|
|
23
|
+
fileId: { type: 'string' },
|
|
24
|
+
filename: { type: 'string' },
|
|
25
|
+
mimeType: { type: 'string' }
|
|
26
|
+
},
|
|
27
|
+
required: ['fileId', 'filename', 'mimeType']
|
|
28
|
+
},
|
|
29
|
+
company: { type: 'string' },
|
|
30
|
+
checklist: { type: 'string' },
|
|
31
|
+
comment: { type: 'string' }
|
|
32
|
+
},
|
|
33
|
+
required: [
|
|
34
|
+
'requestId',
|
|
35
|
+
'fullName',
|
|
36
|
+
'birthDate',
|
|
37
|
+
'grade',
|
|
38
|
+
'location',
|
|
39
|
+
'rate',
|
|
40
|
+
'nds',
|
|
41
|
+
'exitDate',
|
|
42
|
+
'vacation',
|
|
43
|
+
'inStaff',
|
|
44
|
+
'cvFile',
|
|
45
|
+
'company',
|
|
46
|
+
'checklist'
|
|
47
|
+
]
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
// 2. Describe the mapping schema pointing property names to their Form titles/labels
|
|
51
|
+
const mappingSchema: FormSchemaMapping = {
|
|
52
|
+
requestId: { label: 'Номер запроса', type: 'text' },
|
|
53
|
+
fullName: { label: 'ФИО (полностью)', type: 'text' },
|
|
54
|
+
birthDate: { label: 'Дата рождения', type: 'date' },
|
|
55
|
+
grade: { label: 'Грейд', type: 'choice', options: { choices: ['Junior', 'Junior+', 'Middle', 'Middle+', 'Senior', 'Senior+', 'Team Lead'] } },
|
|
56
|
+
location: { label: 'Локация (страна, город)', type: 'text' },
|
|
57
|
+
rate: { label: 'Рейт (руб./ч) без НДС', type: 'text' },
|
|
58
|
+
nds: { label: 'Какой у вас НДС:', type: 'choice', options: { choices: ['5%', '7%', '20%', '22%', 'Нет'] } },
|
|
59
|
+
exitDate: { label: 'Когда кандидат сможет выйти на проект', type: 'paragraph' },
|
|
60
|
+
vacation: { label: 'Ближайший планируемый отпуск', type: 'text' },
|
|
61
|
+
inStaff: { label: 'Находится ли специалист в штате?', type: 'choice', options: { choices: ['Да'], allowOther: true } },
|
|
62
|
+
cvFile: { label: 'CV (файл)', type: 'file' },
|
|
63
|
+
company: { label: 'Название вашей компании и контакты для связи', type: 'text' },
|
|
64
|
+
checklist: { label: 'Чек-лист по требованиям', type: 'paragraph' },
|
|
65
|
+
comment: { label: 'Комментарий', type: 'paragraph' }
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
async function run() {
|
|
69
|
+
console.log('🚀 Starting Google Form Submitter Integration Test...');
|
|
70
|
+
|
|
71
|
+
// Load cookies from local scratch path if it exists
|
|
72
|
+
let cookies: any[] = [];
|
|
73
|
+
const localCookiesPath = path.join(__dirname, '..', 'scratch', 'playwright-submit', 'google-cookies.json');
|
|
74
|
+
try {
|
|
75
|
+
const cookiesStr = await fs.readFile(localCookiesPath, 'utf8');
|
|
76
|
+
cookies = JSON.parse(cookiesStr);
|
|
77
|
+
console.log(`[Test] Loaded ${cookies.length} session cookies from ${localCookiesPath}`);
|
|
78
|
+
} catch (err: any) {
|
|
79
|
+
console.warn(`[Test] Warning: Could not load local session cookies: ${err.message}`);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// 3. Instantiate the Submitter
|
|
83
|
+
const formUrl = 'https://docs.google.com/forms/d/e/1FAIpQLSc1aiEAhxWCMSNjZ1asGDtXV_mT8vTiJRquiqfdjsoMkKT9tw/viewform';
|
|
84
|
+
const submitter = new GoogleFormSubmitter({
|
|
85
|
+
formUrl,
|
|
86
|
+
jsonSchema,
|
|
87
|
+
mappingSchema,
|
|
88
|
+
cdpUrl: 'ws://127.0.0.1:9222/', // CDP endpoint to Lightpanda
|
|
89
|
+
cacheDir: './.data',
|
|
90
|
+
cookies
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
// Mock candidate payload matching the JSON Schema
|
|
94
|
+
const candidateData = {
|
|
95
|
+
requestId: '910',
|
|
96
|
+
fullName: 'Багманов Алмаз (Схема)',
|
|
97
|
+
birthDate: '1996-07-03',
|
|
98
|
+
grade: 'Middle',
|
|
99
|
+
location: 'Россия, Набережные Челны',
|
|
100
|
+
rate: '2000',
|
|
101
|
+
nds: 'Нет',
|
|
102
|
+
exitDate: 'через 21 дней',
|
|
103
|
+
vacation: 'нет',
|
|
104
|
+
inStaff: 'Да', // Standard predefined value
|
|
105
|
+
cvFile: {
|
|
106
|
+
fileId: '1BRVwwPlohb_4g7DUZ3HTzHTwe6yQz2f7', // Real Drive File ID from earlier successful run
|
|
107
|
+
filename: 'cv_Багманов_Алмаз.docx',
|
|
108
|
+
mimeType: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'
|
|
109
|
+
},
|
|
110
|
+
company: 'Iconicompany',
|
|
111
|
+
checklist: '📊 Чек-лист: 77% (Kotlin, Android, Compose, Coroutines)',
|
|
112
|
+
comment: 'Отправлено автоматически через типизированный маппер.'
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
try {
|
|
116
|
+
// Perform first submission (will trigger lazy DOM-resolution and save cache)
|
|
117
|
+
console.log('\n--- FIRST RUN: Performing dynamic DOM-resolution and submission ---');
|
|
118
|
+
const result1 = await submitter.submit(candidateData);
|
|
119
|
+
console.log('Result 1 Success:', result1.success);
|
|
120
|
+
console.log('Result 1 Status Code:', result1.statusCode);
|
|
121
|
+
console.log('Result 1 Fields Submitted:', JSON.stringify(result1.fieldsSubmitted, null, 2));
|
|
122
|
+
|
|
123
|
+
// Perform second submission (will load resolved entryIds instantly from cache)
|
|
124
|
+
console.log('\n--- SECOND RUN: Submitting instantly using cache ---');
|
|
125
|
+
// Change name slightly to verify a new answer is posted
|
|
126
|
+
candidateData.fullName = 'Багманов Алмаз (Схема-Кэш)';
|
|
127
|
+
const result2 = await submitter.submit(candidateData);
|
|
128
|
+
console.log('Result 2 Success:', result2.success);
|
|
129
|
+
console.log('Result 2 Status Code:', result2.statusCode);
|
|
130
|
+
console.log('Result 2 Fields Submitted:', JSON.stringify(result2.fieldsSubmitted, null, 2));
|
|
131
|
+
|
|
132
|
+
console.log('\n🎉 Integration test completed successfully!');
|
|
133
|
+
} catch (error) {
|
|
134
|
+
console.error('❌ Integration test failed:', error);
|
|
135
|
+
process.exit(1);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
run();
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
// Environment setup & latest features
|
|
4
|
+
"lib": ["ESNext", "DOM"],
|
|
5
|
+
"target": "ESNext",
|
|
6
|
+
"module": "Preserve",
|
|
7
|
+
"moduleDetection": "force",
|
|
8
|
+
"jsx": "react-jsx",
|
|
9
|
+
"allowJs": true,
|
|
10
|
+
"types": ["bun"],
|
|
11
|
+
|
|
12
|
+
// Bundler mode
|
|
13
|
+
"moduleResolution": "bundler",
|
|
14
|
+
"allowImportingTsExtensions": true,
|
|
15
|
+
"verbatimModuleSyntax": true,
|
|
16
|
+
"noEmit": true,
|
|
17
|
+
|
|
18
|
+
// Best practices
|
|
19
|
+
"strict": true,
|
|
20
|
+
"skipLibCheck": true,
|
|
21
|
+
"noFallthroughCasesInSwitch": true,
|
|
22
|
+
"noUncheckedIndexedAccess": true,
|
|
23
|
+
"noImplicitOverride": true,
|
|
24
|
+
|
|
25
|
+
// Some stricter flags (disabled by default)
|
|
26
|
+
"noUnusedLocals": false,
|
|
27
|
+
"noUnusedParameters": false,
|
|
28
|
+
"noPropertyAccessFromIndexSignature": false
|
|
29
|
+
}
|
|
30
|
+
}
|
package/types.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export interface FormFieldMapping {
|
|
2
|
+
label: string;
|
|
3
|
+
type: 'text' | 'paragraph' | 'choice' | 'date' | 'file';
|
|
4
|
+
allowOther?: boolean;
|
|
5
|
+
options?: {
|
|
6
|
+
choices?: string[];
|
|
7
|
+
allowOther?: boolean;
|
|
8
|
+
};
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export type FormSchemaMapping = Record<string, FormFieldMapping>;
|
|
12
|
+
|
|
13
|
+
export interface SubmissionResult {
|
|
14
|
+
success: boolean;
|
|
15
|
+
statusCode: number;
|
|
16
|
+
message?: string;
|
|
17
|
+
fieldsSubmitted: Record<string, string>;
|
|
18
|
+
}
|