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/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
@@ -0,0 +1,2 @@
1
+ export * from './types';
2
+ export * from './GoogleFormSubmitter';
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
+ }