googleformsubmitter 1.0.0 → 1.1.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.
package/.env.example CHANGED
@@ -2,3 +2,16 @@
2
2
 
3
3
  # CDP Web Socket endpoint for connecting to the Lightpanda browser engine
4
4
  LIGHTPANDA_CDP_URL=ws://127.0.0.1:9222/
5
+
6
+ # Path to the Google Forms session cookies JSON file (required for testing file upload fields)
7
+ GOOGLE_COOKIES_PATH=./.data/google-cookies.json
8
+
9
+ # Google OAuth2 Credentials (Option A: Direct Env configuration - recommended for Production/CI)
10
+ # GOOGLE_CLIENT_ID=your-client-id
11
+ # GOOGLE_CLIENT_SECRET=your-client-secret
12
+ # GOOGLE_REFRESH_TOKEN=your-refresh-token
13
+
14
+ # Google OAuth2 Credentials Paths (Option B: Custom File-based configuration - fallback option)
15
+ # GOOGLE_OAUTH_CREDENTIALS_PATH=./.data/credentials.json
16
+ # GOOGLE_OAUTH_TOKEN_PATH=./.data/forms-auth.json
17
+
package/README.md CHANGED
@@ -2,29 +2,32 @@
2
2
 
3
3
  Пакет для строго типизированной, валидируемой и кэшируемой отправки данных во внешние (в том числе сторонние) Google Формы с использованием Lightpanda (CDP-соединение) или стандартного Chromium.
4
4
 
5
+ ---
6
+
5
7
  ## 🚀 Возможности
6
8
 
7
9
  1. **Строгая валидация (JSON Schema)**: Входные данные проверяются через стандартную JSON схему с использованием библиотеки `Ajv` перед любой сетевой отправкой.
8
10
  2. **Декларативное описание полей**: Позволяет сопоставлять программные свойства (например, `fullName`, `exitDate`) с русскими заголовками вопросов в Google Форме.
9
11
  3. **Ленивый парсинг DOM (Lazy Loading)**: При первой отправке модуль автоматически открывает форму в безголовом браузере, сканирует структуру вопросов, находит соответствующие им бэкенд `entryId` и сохраняет карту соответствий в локальный кэш-файл.
10
12
  4. **Сверхбыстрые повторные отправки**: После наполнения кэша последующие отправки выполняются мгновенно через прямой HTTP POST-запрос, эмулируя отправку формы без запуска графического браузера.
11
- 5. **Авторизация (Куки)**: Поддерживает инжекцию файлов куки для прохождения авторизации в формах, требующих аккаунт Google (например, если в форме есть поле загрузки файла).
13
+ 5. **Авторизация и загрузка файлов (Google Drive)**: Поддерживает инжекцию файлов куки для прохождения авторизации в формах, требующих аккаунт Google. Также инкапсулирует автоматическую загрузку файлов резюме (`Buffer`) на Google Диск через Google Drive API на лету с подстановкой ID файла в форму.
12
14
  6. **Поддержка опции «Другое»**: Автоматически распознает кастомные текстовые ответы для радио-кнопок/чекбоксов с выбором «Другое» и правильно их кодирует.
15
+ 7. **Инфраструктурная чистота**: Библиотека полностью независима от файловой структуры или конкретных `env`-переменных вашего приложения. Все настройки и пути передаются в конструктор.
13
16
 
14
17
  ---
15
18
 
16
19
  ## 🛠️ Установка и Запуск
17
20
 
18
- Перейдите в каталог `googleformsubmitter` и установите зависимости:
21
+ Перейдите в каталог библиотеки и установите зависимости:
19
22
  ```bash
20
23
  cd googleformsubmitter
21
24
  bun install
22
25
  ```
23
26
 
24
27
  ### Доступные скрипты:
25
- * **Запуск тестов**:
28
+ * **Запуск тестов (стандартный Bun BDD runner)**:
26
29
  ```bash
27
- bun run test
30
+ bun test
28
31
  ```
29
32
  * **Проверка типов (tsgo)**:
30
33
  ```bash
@@ -41,17 +44,42 @@ bun install
41
44
 
42
45
  ---
43
46
 
47
+ ## 🐼 Запуск Lightpanda
48
+
49
+ Для динамического разрешения DOM-структуры (когда кэш еще не заполнен) библиотека использует CDP-соединение с ультра-легким JS-движком **Lightpanda**.
50
+
51
+ Для его локального запуска выполните:
52
+ ```bash
53
+ docker run --rm -p 9222:9222 lightpanda/browser:latest
54
+ ```
55
+
56
+ *При отсутствии запущенного инстанса Lightpanda библиотека автоматически переключится на стандартный headless Chromium в качестве безопасного fallback.*
57
+
58
+ ---
59
+
60
+ ## 🩹 Патч Playwright для работы под Bun
61
+
62
+ В среде выполнения **Bun** есть известная проблема совместимости с CDP-событиями WebSocket, из-за которой стандартный Playwright зависает (hangs) при подключении к Lightpanda.
63
+
64
+ Для решения этой проблемы библиотека поставляется со встроенным патчем:
65
+ * Файл патча: `patches/playwright-core@1.61.1.patch`
66
+ * Регистрация в `package.json` через `"patchedDependencies"`.
67
+
68
+ При запуске `bun install` патч применяется автоматически.
69
+
70
+ ---
71
+
44
72
  ## 📋 Описание Схемы данных
45
73
 
46
74
  Отправка настраивается с помощью двух схем:
47
75
 
48
- 1. **JSON Schema** — описывает типы данных, ограничения, обязательность и формат (например, регулярные выражения для дат).
76
+ 1. **JSON Schema** — описывает типы данных, ограничения, обязательность и формат.
49
77
  2. **Mapping Schema** — описывает связь между ключом в JSON схеме и человекочитаемым заголовком вопроса в Google Форме.
50
78
 
51
79
  ### Пример декларативной конфигурации:
52
80
 
53
81
  ```typescript
54
- import { GoogleFormSubmitter } from './GoogleFormSubmitter';
82
+ import { GoogleFormSubmitter } from 'googleformsubmitter';
55
83
 
56
84
  // 1. Описание типов данных формы (JSON Schema)
57
85
  const jsonSchema = {
@@ -63,11 +91,11 @@ const jsonSchema = {
63
91
  cvFile: {
64
92
  type: 'object',
65
93
  properties: {
66
- fileId: { type: 'string' },
94
+ buffer: { type: 'object' },
67
95
  filename: { type: 'string' },
68
96
  mimeType: { type: 'string' }
69
97
  },
70
- required: ['fileId', 'filename', 'mimeType']
98
+ required: ['filename', 'mimeType']
71
99
  }
72
100
  },
73
101
  required: ['fullName', 'birthDate', 'inStaff', 'cvFile']
@@ -87,7 +115,20 @@ const submitter = new GoogleFormSubmitter({
87
115
  jsonSchema,
88
116
  mappingSchema,
89
117
  cdpUrl: 'ws://127.0.0.1:9222/', // CDP-сервер Lightpanda
90
- cookies: loadedCookies // Сессионные куки (необязательно)
118
+ cacheDir: './.data', // Папка для кэширования entryId
119
+ cookies: loadedCookies, // Сессионные куки (для авторизованных форм)
120
+
121
+ // Конфигурация авторизации Google Auth (Drive API)
122
+ auth: {
123
+ // Вариант А: Программная передача токенов напрямую (рекомендуется для Production/CI)
124
+ clientId: process.env.GOOGLE_CLIENT_ID,
125
+ clientSecret: process.env.GOOGLE_CLIENT_SECRET,
126
+ refreshToken: process.env.GOOGLE_REFRESH_TOKEN,
127
+
128
+ // Вариант Б: Явные пути к конфигурационным файлам на диске
129
+ // credentialsPath: './.data/credentials.json',
130
+ // tokenPath: './.data/forms-auth.json'
131
+ }
91
132
  });
92
133
 
93
134
  // 4. Отправка данных
@@ -96,7 +137,7 @@ const result = await submitter.submit({
96
137
  birthDate: '1990-01-01',
97
138
  inStaff: 'С рынка, на предоффере', // Будет передано в "Другое"
98
139
  cvFile: {
99
- fileId: '1BRVwwPlohb_4g7DUZ3HTz...',
140
+ buffer: resumeBuffer,
100
141
  filename: 'resume.docx',
101
142
  mimeType: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'
102
143
  }
@@ -107,9 +148,11 @@ const result = await submitter.submit({
107
148
 
108
149
  ## 📂 Структура каталогов проекта
109
150
 
110
- * `GoogleFormSubmitter.ts` — основной класс управления, парсинга DOM и сетевых запросов.
111
- * `create-form.ts` — скрипт автоматического создания тестовой Google-формы со всеми необходимыми типами полей (включая текстовые, списки, даты, переключатели) через официальный Google Forms API.
112
- * `test.ts` — интеграционный тест, эмулирующий первую отправку динамическим парсингом) и повторную отправку (из кэша).
113
- * `types.ts` — TypeScript-интерфейсы и типы данных.
114
- * `.data/`каталог для хранения временных кэшей сопоставлений entryId (`form-cache-*.json`).
115
- * `.env.example` — шаблон конфигурационного файла.
151
+ * `src/google-form-submitter.ts` — основной класс управления, парсинга DOM и сетевых запросов.
152
+ * `src/google-auth-service.ts` — сервис авторизации Google OAuth2 (поддерживает как токены из конструктора/env, так и из файлов).
153
+ * `src/google-drive-service.ts` — сервис загрузки файлов на Google Диск через Google Drive API.
154
+ * `src/types.ts` — TypeScript-интерфейсы и типы данных.
155
+ * `src/index.ts`точка входа (публичные экспорты библиотеки).
156
+ * `src/google-form-submitter.test.ts` — BDD интеграционный тест (запускается через `bun test`).
157
+ * `tools/create-form.ts` — вспомогательный скрипт создания тестовой формы через Google Forms API.
158
+ * `patches/` — содержит патч для решения проблем с CDP Playwright под Bun.
package/package.json CHANGED
@@ -1,13 +1,16 @@
1
1
  {
2
2
  "name": "googleformsubmitter",
3
- "version": "1.0.0",
4
- "module": "index.ts",
3
+ "version": "1.1.1",
4
+ "module": "src/index.ts",
5
+ "main": "src/index.ts",
6
+ "types": "src/index.ts",
5
7
  "type": "module",
6
8
  "scripts": {
7
- "test": "bun run ./test.ts",
9
+ "test": "bun test",
8
10
  "lint": "oxlint",
9
11
  "validate": "tsgo --noEmit",
10
- "create-form": "bun run ./create-form.ts"
12
+ "create-form": "bun run ./tools/create-form.ts",
13
+ "introspect-form": "bun run ./tools/introspect-form.ts"
11
14
  },
12
15
  "devDependencies": {
13
16
  "@types/bun": "latest",
@@ -22,5 +25,8 @@
22
25
  "ajv": "^8.20.0",
23
26
  "googleapis": "^173.0.0",
24
27
  "playwright": "^1.61.1"
28
+ },
29
+ "patchedDependencies": {
30
+ "playwright-core@1.61.1": "patches/playwright-core@1.61.1.patch"
25
31
  }
26
32
  }
@@ -0,0 +1,31 @@
1
+ diff --git a/lib/coreBundle.js b/lib/coreBundle.js
2
+ index 7d8468fd89fd4d292eade471c36e757c23cbec44..6540f61c85efbb3e14d02cd06400750ed754731b 100644
3
+ --- a/lib/coreBundle.js
4
+ +++ b/lib/coreBundle.js
5
+ @@ -38873,7 +38873,7 @@ var init_transport = __esm({
6
+ "use strict";
7
+ init_happyEyeballs();
8
+ init_task();
9
+ - ws = require("./utilsBundle").ws;
10
+ + ws = "Bun" in globalThis ? require(require.resolve("ws", { paths: ["/"] })) : require("./utilsBundle").ws;
11
+ perMessageDeflate2 = {
12
+ clientNoContextTakeover: true,
13
+ zlibDeflateOptions: {
14
+ @@ -49404,7 +49404,7 @@ var init_wvBrowser = __esm({
15
+ init_dialogBridge();
16
+ init_wvConnection();
17
+ init_wvPage();
18
+ - ws2 = require("./utilsBundle").ws;
19
+ + ws2 = "Bun" in globalThis ? require(require.resolve("ws", { paths: ["/"] })) : require("./utilsBundle").ws;
20
+ DeferredWebSocketTransport = class {
21
+ constructor(url2, headers) {
22
+ this._closed = false;
23
+ @@ -71153,7 +71153,7 @@ var init_cdpRelay = __esm({
24
+ init_cdpRelayV2();
25
+ init_protocol();
26
+ debug15 = require("./utilsBundle").debug;
27
+ - ws3 = require("./utilsBundle").ws;
28
+ + ws3 = "Bun" in globalThis ? require(require.resolve("ws", { paths: ["/"] })) : require("./utilsBundle").ws;
29
+ ({ wsServer: wsServer3 } = require("./utilsBundle"));
30
+ debugLogger2 = debug15("pw:mcp:relay");
31
+ CDPRelayServer = class {
@@ -0,0 +1,64 @@
1
+ import fs from 'fs/promises';
2
+ import { google } from 'googleapis';
3
+ import type { GoogleAuthOptions } from './types';
4
+
5
+ export class GoogleAuthService {
6
+ private authOptions?: GoogleAuthOptions;
7
+
8
+ constructor(authOptions?: GoogleAuthOptions) {
9
+ this.authOptions = authOptions;
10
+ }
11
+
12
+ /**
13
+ * Resolves Google Auth OAuth2 Client using explicitly provided credentials or paths.
14
+ */
15
+ public async getGoogleAuthClient(): Promise<any> {
16
+ // 1. Try explicit programmatic credentials
17
+ const clientId = this.authOptions?.clientId;
18
+ const clientSecret = this.authOptions?.clientSecret;
19
+ const refreshToken = this.authOptions?.refreshToken;
20
+
21
+ if (clientId && clientSecret && refreshToken) {
22
+ const oauth2Client = new google.auth.OAuth2(clientId, clientSecret);
23
+ oauth2Client.setCredentials({ refresh_token: refreshToken });
24
+ await oauth2Client.getAccessToken();
25
+ return oauth2Client;
26
+ }
27
+
28
+ // 2. Try explicit file paths
29
+ const credentialsPath = this.authOptions?.credentialsPath;
30
+ const tokenPath = this.authOptions?.tokenPath;
31
+
32
+ if (!credentialsPath || !tokenPath) {
33
+ throw new Error(
34
+ '[GoogleFormSubmitter] Google OAuth credentials are not configured. ' +
35
+ 'Please pass { clientId, clientSecret, refreshToken } programmatically, ' +
36
+ 'or specify both { credentialsPath, tokenPath } in auth options.'
37
+ );
38
+ }
39
+
40
+ // Load credentials from credentials.json
41
+ const credentialsContent = await fs.readFile(credentialsPath, 'utf-8');
42
+ const credentials = JSON.parse(credentialsContent);
43
+ const fileClientId = credentials.client_id || credentials.web?.client_id || credentials.installed?.client_id;
44
+ const fileClientSecret = credentials.client_secret || credentials.web?.client_secret || credentials.installed?.client_secret;
45
+
46
+ // Load token from forms-auth.json
47
+ const tokenContent = await fs.readFile(tokenPath, 'utf-8');
48
+ const token = JSON.parse(tokenContent);
49
+ const fileRefreshToken = token.refresh_token;
50
+
51
+ if (!fileClientId || !fileClientSecret || !fileRefreshToken) {
52
+ throw new Error(
53
+ '[GoogleFormSubmitter] Invalid OAuth configuration in provided credential files: ' +
54
+ 'client_id, client_secret, or refresh_token is missing.'
55
+ );
56
+ }
57
+
58
+ const oauth2Client = new google.auth.OAuth2(fileClientId, fileClientSecret);
59
+ oauth2Client.setCredentials({ refresh_token: fileRefreshToken });
60
+
61
+ await oauth2Client.getAccessToken();
62
+ return oauth2Client;
63
+ }
64
+ }
@@ -0,0 +1,36 @@
1
+ import { google } from 'googleapis';
2
+ import { Readable } from 'stream';
3
+ import type { GoogleAuthService } from './google-auth-service';
4
+
5
+ export class GoogleDriveService {
6
+ private authService: GoogleAuthService;
7
+
8
+ constructor(authService: GoogleAuthService) {
9
+ this.authService = authService;
10
+ }
11
+
12
+ /**
13
+ * Uploads a file buffer directly to Google Drive.
14
+ */
15
+ public async uploadFile(filename: string, mimeType: string, buffer: Buffer): Promise<string> {
16
+ const auth = await this.authService.getGoogleAuthClient();
17
+ const drive = google.drive({ version: 'v3', auth });
18
+
19
+ const response = await drive.files.create({
20
+ requestBody: {
21
+ name: filename
22
+ },
23
+ media: {
24
+ mimeType,
25
+ body: Readable.from(buffer)
26
+ },
27
+ fields: 'id'
28
+ });
29
+
30
+ const id = response.data.id;
31
+ if (!id) {
32
+ throw new Error('[GoogleFormSubmitter] Failed to upload file to Google Drive: response did not contain file ID');
33
+ }
34
+ return id;
35
+ }
36
+ }
@@ -1,4 +1,5 @@
1
- import { GoogleFormSubmitter } from './GoogleFormSubmitter';
1
+ import { describe, expect, test, beforeAll } from 'bun:test';
2
+ import { GoogleFormSubmitter } from './google-form-submitter';
2
3
  import type { FormSchemaMapping } from './types';
3
4
  import fs from 'fs/promises';
4
5
  import path from 'path';
@@ -21,10 +22,11 @@ const jsonSchema = {
21
22
  type: 'object',
22
23
  properties: {
23
24
  fileId: { type: 'string' },
25
+ buffer: { type: 'object' },
24
26
  filename: { type: 'string' },
25
27
  mimeType: { type: 'string' }
26
28
  },
27
- required: ['fileId', 'filename', 'mimeType']
29
+ required: ['filename', 'mimeType']
28
30
  },
29
31
  company: { type: 'string' },
30
32
  checklist: { type: 'string' },
@@ -65,75 +67,74 @@ const mappingSchema: FormSchemaMapping = {
65
67
  comment: { label: 'Комментарий', type: 'paragraph' }
66
68
  };
67
69
 
68
- async function run() {
69
- console.log('🚀 Starting Google Form Submitter Integration Test...');
70
-
71
- // Load cookies from local scratch path if it exists
70
+ describe('GoogleFormSubmitter E2E Integration', () => {
72
71
  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
- }
72
+ let submitter: GoogleFormSubmitter;
73
+
74
+ beforeAll(async () => {
75
+ // Load cookies from local environment path if it exists
76
+ const localCookiesPath = process.env.GOOGLE_COOKIES_PATH || path.join(__dirname, '..', '.data', 'google-cookies.json');
77
+ try {
78
+ const cookiesStr = await fs.readFile(localCookiesPath, 'utf8');
79
+ cookies = JSON.parse(cookiesStr);
80
+ console.log(`[Test] Loaded ${cookies.length} session cookies from ${localCookiesPath}`);
81
+ } catch (err: any) {
82
+ console.warn(`[Test] Warning: Could not load local session cookies: ${err.message}`);
83
+ }
84
+
85
+ const formUrl = 'https://docs.google.com/forms/d/e/1FAIpQLSc1aiEAhxWCMSNjZ1asGDtXV_mT8vTiJRquiqfdjsoMkKT9tw/viewform';
86
+ const credentialsPath = path.join(__dirname, '..', '..', 'imatching', '.data', 'credentials.json');
87
+ const tokenPath = path.join(__dirname, '..', '..', 'imatching', '.data', 'forms-auth.json');
81
88
 
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
89
+ submitter = new GoogleFormSubmitter({
90
+ formUrl,
91
+ jsonSchema,
92
+ mappingSchema,
93
+ cdpUrl: 'ws://127.0.0.1:9222/', // CDP endpoint to Lightpanda
94
+ cacheDir: './.data',
95
+ auth: {
96
+ credentialsPath,
97
+ tokenPath
98
+ },
99
+ cookies
100
+ });
91
101
  });
92
102
 
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
- };
103
+ test('should dynamically resolve fields, upload file, and submit successfully', async () => {
104
+ const candidateData = {
105
+ requestId: '910',
106
+ fullName: 'Багманов Алмаз (Схема)',
107
+ birthDate: '1996-07-03',
108
+ grade: 'Middle',
109
+ location: 'Россия, Набережные Челны',
110
+ rate: '2000',
111
+ nds: 'Нет',
112
+ exitDate: 'через 21 дней',
113
+ vacation: 'нет',
114
+ inStaff: 'Да',
115
+ cvFile: {
116
+ buffer: Buffer.from('mock cv file contents for integration test'),
117
+ filename: 'cv_Багманов_Алмаз.docx',
118
+ mimeType: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'
119
+ },
120
+ company: 'Iconicompany',
121
+ checklist: '📊 Чек-лист: 77% (Kotlin, Android, Compose, Coroutines)',
122
+ comment: 'Отправлено автоматически через типизированный маппер.'
123
+ };
114
124
 
115
- try {
116
- // Perform first submission (will trigger lazy DOM-resolution and save cache)
117
125
  console.log('\n--- FIRST RUN: Performing dynamic DOM-resolution and submission ---');
118
126
  const result1 = await submitter.submit(candidateData);
119
- console.log('Result 1 Success:', result1.success);
120
- console.log('Result 1 Status Code:', result1.statusCode);
121
127
  console.log('Result 1 Fields Submitted:', JSON.stringify(result1.fieldsSubmitted, null, 2));
122
128
 
123
- // Perform second submission (will load resolved entryIds instantly from cache)
129
+ expect(result1.success).toBe(true);
130
+ expect(result1.statusCode).toBe(200);
131
+
124
132
  console.log('\n--- SECOND RUN: Submitting instantly using cache ---');
125
- // Change name slightly to verify a new answer is posted
126
133
  candidateData.fullName = 'Багманов Алмаз (Схема-Кэш)';
127
134
  const result2 = await submitter.submit(candidateData);
128
- console.log('Result 2 Success:', result2.success);
129
- console.log('Result 2 Status Code:', result2.statusCode);
130
135
  console.log('Result 2 Fields Submitted:', JSON.stringify(result2.fieldsSubmitted, null, 2));
131
136
 
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();
137
+ expect(result2.success).toBe(true);
138
+ expect(result2.statusCode).toBe(200);
139
+ }, 45000); // 45 seconds timeout
140
+ });
@@ -2,7 +2,9 @@ import { chromium } from 'playwright';
2
2
  import Ajv from 'ajv';
3
3
  import fs from 'fs/promises';
4
4
  import path from 'path';
5
- import type { FormSchemaMapping, SubmissionResult } from './types';
5
+ import type { FormSchemaMapping, SubmissionResult, GoogleAuthOptions } from './types';
6
+ import { GoogleAuthService } from './google-auth-service';
7
+ import { GoogleDriveService } from './google-drive-service';
6
8
 
7
9
  const ajv = new Ajv({ allErrors: true });
8
10
 
@@ -15,6 +17,8 @@ export class GoogleFormSubmitter {
15
17
  private entryIdCache: Record<string, string> | null = null;
16
18
  private validateFn: ReturnType<typeof ajv.compile>;
17
19
  private cookies?: Array<any>;
20
+ private authService: GoogleAuthService;
21
+ private driveService: GoogleDriveService;
18
22
 
19
23
  constructor(options: {
20
24
  formUrl: string;
@@ -23,13 +27,17 @@ export class GoogleFormSubmitter {
23
27
  cdpUrl?: string;
24
28
  cacheDir?: string;
25
29
  cookies?: Array<any>;
30
+ auth?: GoogleAuthOptions;
26
31
  }) {
27
32
  this.formUrl = options.formUrl;
28
33
  this.jsonSchema = options.jsonSchema;
29
34
  this.mappingSchema = options.mappingSchema;
30
35
  this.cdpUrl = options.cdpUrl || 'ws://127.0.0.1:9222/';
31
36
  this.cookies = options.cookies;
32
-
37
+
38
+ this.authService = new GoogleAuthService(options.auth);
39
+ this.driveService = new GoogleDriveService(this.authService);
40
+
33
41
  // Compile JSON Schema validation function
34
42
  this.validateFn = ajv.compile(this.jsonSchema);
35
43
 
@@ -37,7 +45,7 @@ export class GoogleFormSubmitter {
37
45
  const formIdMatch = this.formUrl.match(/\/d\/e\/([a-zA-Z0-9_-]+)/) || this.formUrl.match(/\/d\/([a-zA-Z0-9_-]+)/);
38
46
  const formHash = formIdMatch ? formIdMatch[1] : Buffer.from(this.formUrl).toString('base64').substring(0, 16);
39
47
 
40
- const cacheDir = options.cacheDir || path.join(process.cwd(), '.data');
48
+ const cacheDir = options.cacheDir || process.cwd();
41
49
  this.cacheFilePath = path.join(cacheDir, `form-cache-${formHash}.json`);
42
50
  }
43
51
 
@@ -83,13 +91,13 @@ export class GoogleFormSubmitter {
83
91
 
84
92
  try {
85
93
  await page.goto(this.formUrl);
86
- // Wait for fields to load
87
- await page.locator('.Qr7Oae').first().waitFor({ state: 'attached', timeout: 15000 });
94
+ // Wait for page load and check if form is closed
95
+ await this.checkClosedAndGetPage(page);
88
96
 
89
97
  // Scan DOM for question containers
90
98
  const domMappings = await page.evaluate(() => {
91
99
  const result: Record<string, string> = {};
92
- const containers = document.querySelectorAll('.Qr7Oae');
100
+ const containers = (globalThis as any).document.querySelectorAll('.Qr7Oae');
93
101
 
94
102
  containers.forEach((container: any) => {
95
103
  const headerEl = container.querySelector('[role="heading"], .M7eMe, .HoRgec');
@@ -111,24 +119,19 @@ export class GoogleFormSubmitter {
111
119
  }
112
120
  }
113
121
 
114
- // Strategy B: Parse from data-params if Strategy A fails
122
+ // Strategy B: Parse data-params attribute in container or child elements
115
123
  if (!entryId) {
116
- let dataParams = container.getAttribute('data-params') || '';
117
- if (!dataParams) {
118
- const el = container.querySelector('[data-params]');
119
- if (el) {
120
- dataParams = el.getAttribute('data-params') || '';
121
- }
122
- }
123
- if (dataParams) {
124
- const match = dataParams.match(/\[\[(\d+)/);
124
+ const paramsEl = container.hasAttribute('data-params') ? container : container.querySelector('[data-params]');
125
+ if (paramsEl) {
126
+ const dataParams = paramsEl.getAttribute('data-params');
127
+ const match = dataParams?.match(/\[\[(\d+)/);
125
128
  if (match && match[1]) {
126
129
  entryId = match[1];
127
130
  }
128
131
  }
129
132
  }
130
133
 
131
- if (labelText && entryId) {
134
+ if (entryId) {
132
135
  result[labelText] = entryId;
133
136
  }
134
137
  });
@@ -136,26 +139,13 @@ export class GoogleFormSubmitter {
136
139
  return result;
137
140
  });
138
141
 
139
- // 3. Map mappingSchema properties to resolved entry IDs
142
+ console.log(`[GoogleFormSubmitter] Discovered ${Object.keys(domMappings).length} fields in Form DOM.`);
143
+
144
+ // 3. Map properties from mappingSchema to Google Forms entryIds
140
145
  const resolvedCache: Record<string, string> = {};
141
146
  for (const [propName, fieldMapping] of Object.entries(this.mappingSchema)) {
142
- // Try finding a match in the scraped titles
143
- let matchedEntryId: string | undefined = undefined;
144
-
145
- // Try exact match first
146
- matchedEntryId = domMappings[fieldMapping.label];
147
-
148
- // Try substring match as fallback
149
- if (!matchedEntryId) {
150
- const lowerLabel = fieldMapping.label.toLowerCase();
151
- const scrapedTitle = Object.keys(domMappings).find(
152
- (t) => t.toLowerCase().includes(lowerLabel) || lowerLabel.includes(t.toLowerCase())
153
- );
154
- if (scrapedTitle) {
155
- matchedEntryId = domMappings[scrapedTitle];
156
- }
157
- }
158
-
147
+ // Find matching entryId by label
148
+ const matchedEntryId = domMappings[fieldMapping.label];
159
149
  if (matchedEntryId) {
160
150
  resolvedCache[propName] = matchedEntryId;
161
151
  } else {
@@ -176,6 +166,26 @@ export class GoogleFormSubmitter {
176
166
  }
177
167
  }
178
168
 
169
+ /**
170
+ * Safe check to determine if the form is closed, throwing a clear error instead of timing out.
171
+ */
172
+ private async checkClosedAndGetPage(page: any): Promise<void> {
173
+ await Promise.any([
174
+ page.locator('.Qr7Oae').first().waitFor({ state: 'attached', timeout: 15000 }),
175
+ page.waitForURL(/\/closedform/, { timeout: 15000 }).catch(() => {})
176
+ ]).catch(() => {});
177
+
178
+ const isClosed = page.url().includes('/closedform') || (await page.locator('form').count() === 0);
179
+
180
+ if (isClosed) {
181
+ throw new Error('Google Form is closed (no longer accepting responses)');
182
+ }
183
+
184
+ if (await page.locator('.Qr7Oae').count() === 0) {
185
+ throw new Error('Could not find question containers on the page.');
186
+ }
187
+ }
188
+
179
189
  /**
180
190
  * Validates input data and submits the form via direct HTTP POST.
181
191
  */
@@ -186,7 +196,7 @@ export class GoogleFormSubmitter {
186
196
  const errorMsg = this.validateFn.errors
187
197
  ?.map((err) => `${err.instancePath} ${err.message}`)
188
198
  .join(', ');
189
- throw new Error(`Validation failed: ${errorMsg}`);
199
+ throw new Error("Validation failed: " + errorMsg);
190
200
  }
191
201
 
192
202
  const typedData = data as Record<string, any>;
@@ -215,11 +225,18 @@ export class GoogleFormSubmitter {
215
225
  const currentList = fieldsMap[entryId];
216
226
 
217
227
  if (fieldMapping.type === 'file') {
218
- if (value && typeof value === 'object' && 'fileId' in value) {
219
- const filePayload = [[[value.fileId, value.filename, value.mimeType]]];
220
- const serialized = JSON.stringify(filePayload);
221
- currentList?.push(serialized);
222
- fieldsSubmitted[fieldMapping.label] = serialized;
228
+ if (value && typeof value === 'object' && ('fileId' in value || 'buffer' in value)) {
229
+ let fileId = value.fileId;
230
+ if (!fileId && value.buffer) {
231
+ console.log(`[GoogleFormSubmitter] File buffer detected. Uploading "${value.filename}" to Google Drive...`);
232
+ fileId = await this.driveService.uploadFile(value.filename, value.mimeType, value.buffer);
233
+ }
234
+ if (fileId) {
235
+ const filePayload = [[[fileId, value.filename, value.mimeType]]];
236
+ const serialized = JSON.stringify(filePayload);
237
+ currentList?.push(serialized);
238
+ fieldsSubmitted[fieldMapping.label] = serialized;
239
+ }
223
240
  }
224
241
  } else if (fieldMapping.type === 'choice' && fieldMapping.allowOther && value) {
225
242
  const isPredefined = fieldMapping.options?.choices?.includes(value);
@@ -264,7 +281,7 @@ export class GoogleFormSubmitter {
264
281
 
265
282
  try {
266
283
  await page.goto(this.formUrl);
267
- await page.locator('.Qr7Oae').first().waitFor({ state: 'attached', timeout: 15000 });
284
+ await this.checkClosedAndGetPage(page);
268
285
 
269
286
  console.log(`[GoogleFormSubmitter] Sending HTTP POST request in page context...`);
270
287
  const responseInfo = await page.evaluate(async ({ fieldsMap }) => {
@@ -282,34 +299,38 @@ export class GoogleFormSubmitter {
282
299
  }
283
300
 
284
301
  for (const [entryId, values] of Object.entries(fieldsMap)) {
285
- for (const val of values as string[]) {
302
+ const list = values as string[];
303
+ for (const val of list) {
286
304
  searchParams.append(`entry.${entryId}`, val);
287
305
  }
288
306
  }
289
307
 
290
- const actionUrl = form.action || g.location.href.replace('/viewform', '/formResponse');
291
-
292
- const res = await g.fetch(actionUrl, {
308
+ const actionUrl = form.getAttribute('action') || '';
309
+ const resolvedActionUrl = new URL(actionUrl, g.location.href).href;
310
+
311
+ console.log(`Sending POST to ${resolvedActionUrl}...`);
312
+ const response = await g.fetch(resolvedActionUrl, {
293
313
  method: 'POST',
294
- body: searchParams,
295
314
  headers: {
296
315
  'Content-Type': 'application/x-www-form-urlencoded'
297
- }
316
+ },
317
+ body: searchParams.toString()
298
318
  });
299
319
 
300
320
  return {
301
- status: res.status,
302
- text: await res.text()
321
+ status: response.status,
322
+ url: response.url,
323
+ bodyText: await response.text()
303
324
  };
304
325
  }, { fieldsMap });
305
326
 
306
- const isSuccess = responseInfo.status === 200 || responseInfo.text.includes('v-confirmation-msg') || responseInfo.text.toLowerCase().includes('confirmation');
327
+ const success = responseInfo.status === 200;
307
328
 
308
329
  return {
309
- success: isSuccess,
330
+ success,
310
331
  statusCode: responseInfo.status,
311
- message: isSuccess ? 'Form submitted successfully!' : 'Google Form rejected submission.',
312
- fieldsSubmitted,
332
+ message: success ? 'Form submitted successfully' : `Server responded with status ${responseInfo.status}`,
333
+ fieldsSubmitted
313
334
  };
314
335
  } finally {
315
336
  await context.close();
package/src/index.ts ADDED
@@ -0,0 +1,4 @@
1
+ export * from './types';
2
+ export * from './google-form-submitter';
3
+ export * from './google-auth-service';
4
+ export * from './google-drive-service';
@@ -16,3 +16,11 @@ export interface SubmissionResult {
16
16
  message?: string;
17
17
  fieldsSubmitted: Record<string, string>;
18
18
  }
19
+
20
+ export interface GoogleAuthOptions {
21
+ clientId?: string;
22
+ clientSecret?: string;
23
+ refreshToken?: string;
24
+ credentialsPath?: string;
25
+ tokenPath?: string;
26
+ }
@@ -0,0 +1,264 @@
1
+ import { chromium } from 'playwright';
2
+ import fs from 'fs/promises';
3
+ import path from 'path';
4
+
5
+ function toCamelCase(str: string): string {
6
+ // Map common Russian terms to standard project English keys
7
+ const translations: Record<string, string> = {
8
+ 'фио': 'fullName',
9
+ 'дата рождения': 'birthDate',
10
+ 'грейд': 'grade',
11
+ 'локация': 'location',
12
+ 'рейт': 'rate',
13
+ 'ндс': 'nds',
14
+ 'когда кандидат': 'exitDate',
15
+ 'отпуск': 'vacation',
16
+ 'штате': 'inStaff',
17
+ 'cv': 'cvFile',
18
+ 'резюме': 'cvFile',
19
+ 'компания': 'company',
20
+ 'контакты': 'contacts',
21
+ 'требованиям': 'checklist',
22
+ 'комментарий': 'comment',
23
+ 'номер запроса': 'requestId'
24
+ };
25
+
26
+ const lower = str.toLowerCase();
27
+ for (const [key, val] of Object.entries(translations)) {
28
+ if (lower.includes(key)) {
29
+ return val;
30
+ }
31
+ }
32
+
33
+ // Fallback to transliteration + camelCase
34
+ const clean = str
35
+ .replace(/[^a-zA-Zа-яА-Я0-9\s]/g, '')
36
+ .trim()
37
+ .replace(/\s+/g, ' ');
38
+
39
+ const ru: Record<string, string> = {
40
+ 'а':'a', 'б':'b', 'в':'v', 'г':'g', 'д':'d', 'е':'e', 'ё':'yo', 'ж':'zh', 'з':'z', 'и':'i', 'й':'y', 'к':'k', 'л':'l', 'м':'m', 'н':'n', 'о':'o', 'п':'p', 'р':'r', 'с':'s', 'т':'t', 'у':'u', 'ф':'f', 'х':'h', 'ц':'ts', 'ч':'ch', 'ш':'sh', 'щ':'sch', 'ъ':'', 'ы':'y', 'ь':'', 'э':'e', 'ю':'yu', 'я':'ya',
41
+ 'А':'A', 'Б':'B', 'В':'V', 'Г':'G', 'Д':'D', 'Е':'E', 'Ё':'Yo', 'Ж':'Zh', 'З':'Z', 'И':'I', 'Й':'Y', 'К':'K', 'Л':'L', 'М':'M', 'Н':'N', 'О':'O', 'П':'P', 'Р':'R', 'С':'S', 'Т':'T', 'У':'U', 'Ф':'F', 'Х':'H', 'Ц':'Ts', 'Ч':'Ch', 'Ш':'Sh', 'Щ':'Sch', 'Ъ':'', 'Ы':'Y', 'Ь':'', 'Э':'E', 'Ю':'Yu', 'Я':'Ya'
42
+ };
43
+
44
+ let transliterated = '';
45
+ for (const char of clean) {
46
+ const replacement = ru[char] !== undefined ? ru[char] : char;
47
+ transliterated += replacement;
48
+ }
49
+
50
+ const words = transliterated.split(' ');
51
+ return words
52
+ .map((word, idx) => {
53
+ if (idx === 0) return word.toLowerCase();
54
+ return word.charAt(0).toUpperCase() + word.slice(1).toLowerCase();
55
+ })
56
+ .join('');
57
+ }
58
+
59
+ async function main() {
60
+ const url = process.argv[2];
61
+ if (!url) {
62
+ console.error('Usage: bun run ./tools/introspect-form.ts <google-form-url>');
63
+ process.exit(1);
64
+ }
65
+
66
+ console.log(`🔍 Connecting to Google Form at: ${url}...`);
67
+
68
+ let browser;
69
+ try {
70
+ // Attempt connecting to local Lightpanda first
71
+ browser = await chromium.connectOverCDP(process.env.LIGHTPANDA_CDP_URL || 'ws://127.0.0.1:9222/', { timeout: 5000 });
72
+ } catch {
73
+ console.log('Lightpanda not found, falling back to local headless Chromium...');
74
+ browser = await chromium.launch({ headless: true });
75
+ }
76
+
77
+ const context = await browser.newContext();
78
+
79
+ // Load session cookies if GOOGLE_COOKIES_PATH is configured
80
+ const cookiesPath = process.env.GOOGLE_COOKIES_PATH;
81
+ if (cookiesPath) {
82
+ try {
83
+ const cookiesStr = await fs.readFile(cookiesPath, 'utf8');
84
+ const cookies = JSON.parse(cookiesStr);
85
+ await context.addCookies(cookies);
86
+ console.log(`[Introspect] Injected ${cookies.length} session cookies from ${cookiesPath}`);
87
+ } catch (e: any) {
88
+ console.warn(`[Introspect] Warning: Could not inject cookies: ${e.message}`);
89
+ }
90
+ }
91
+
92
+ const page = await context.newPage();
93
+ page.on('console', msg => console.log('PAGE LOG:', msg.text()));
94
+
95
+ try {
96
+ await page.goto(url);
97
+
98
+ // Wait for the form contents to load
99
+ await Promise.any([
100
+ page.locator('.Qr7Oae').first().waitFor({ state: 'attached', timeout: 15000 }),
101
+ page.waitForURL(/\/closedform/, { timeout: 15000 }).catch(() => {})
102
+ ]).catch(() => {});
103
+
104
+ if (page.url().includes('/closedform') || (await page.locator('form').count() === 0)) {
105
+ throw new Error('This Google Form is closed or redirecting to login. Make sure your session cookies are valid.');
106
+ }
107
+
108
+ // Inspect DOM
109
+ const questions = await page.evaluate(() => {
110
+ const g = globalThis as any;
111
+ const result: any[] = [];
112
+ const containers = g.document.querySelectorAll('.Qr7Oae');
113
+
114
+ containers.forEach((container: any) => {
115
+ const headerEl = container.querySelector('[role="heading"], .M7eMe, .HoRgec');
116
+ if (!headerEl) return;
117
+
118
+ let labelText = (headerEl.textContent || '').trim();
119
+ const isRequired = labelText.endsWith('*');
120
+ labelText = labelText.replace(/\s*\*$/, '').trim();
121
+
122
+ let type = 'text';
123
+ let choices: string[] = [];
124
+ let allowOther = false;
125
+
126
+ const paramsEl = container.hasAttribute('data-params') ? container : container.querySelector('[data-params]');
127
+ if (paramsEl) {
128
+ const dataParams = paramsEl.getAttribute('data-params');
129
+ try {
130
+ // Replace Google Forms prefix with '[' to restore the outer array opening bracket
131
+ const jsonText = dataParams.replace(/^%\.@\./, '[');
132
+ const parsed = JSON.parse(jsonText);
133
+ const questionInfo = parsed[0];
134
+ const typeId = questionInfo[3];
135
+
136
+ if (typeId === 11 || typeId === 13) {
137
+ type = 'file';
138
+ } else if (typeId === 9) {
139
+ type = 'date';
140
+ } else if (typeId === 1) {
141
+ type = 'paragraph';
142
+ } else if (typeId === 2 || typeId === 3 || typeId === 4) {
143
+ type = 'choice';
144
+ const choicesArray = questionInfo[4]?.[0]?.[1];
145
+ if (choicesArray && Array.isArray(choicesArray)) {
146
+ choices = choicesArray.map((c: any) => c[0]).filter((val) => val !== null && val !== undefined && val !== '');
147
+ }
148
+ allowOther = !!questionInfo[4]?.[0]?.[2];
149
+ }
150
+ } catch (e: any) {
151
+ console.error('PAGE LOG [Parser Error]:', e.message, 'Data:', dataParams);
152
+ // Fallback to legacy DOM checks if parse fails
153
+ if (container.querySelector('[type="file"]') || container.querySelector('[data-params*="file"]')) {
154
+ type = 'file';
155
+ } else if (container.querySelector('input[type="date"]') || container.querySelector('[data-params*="[[3,"]')) {
156
+ type = 'date';
157
+ } else if (container.querySelector('[role="radio"], [role="checkbox"], select, .SGZTVe')) {
158
+ type = 'choice';
159
+ const optionEls = container.querySelectorAll('[role="radio"] + *, [role="checkbox"] + *, option, .fwW70c, .text');
160
+ optionEls.forEach((opt: any) => {
161
+ const text = (opt.textContent || '').trim();
162
+ if (text && !text.toLowerCase().includes('другое') && !text.toLowerCase().includes('other')) {
163
+ choices.push(text);
164
+ }
165
+ });
166
+ } else if (container.querySelector('textarea')) {
167
+ type = 'paragraph';
168
+ }
169
+ }
170
+ } else {
171
+ console.warn('PAGE LOG: paramsEl not found for label:', labelText);
172
+ }
173
+
174
+ result.push({
175
+ label: labelText,
176
+ type,
177
+ required: isRequired,
178
+ choices: choices.length > 0 ? [...new Set(choices)] : undefined,
179
+ allowOther: allowOther || undefined
180
+ });
181
+ });
182
+
183
+ return result;
184
+ });
185
+
186
+ console.log(`\n🎉 Introspected ${questions.length} fields successfully. Generating schema objects...\n`);
187
+
188
+ const jsonProperties: Record<string, any> = {};
189
+ const jsonRequired: string[] = [];
190
+ const mappingSchema: Record<string, any> = {};
191
+
192
+ for (const q of questions) {
193
+ const key = toCamelCase(q.label);
194
+
195
+ // JSON Schema building
196
+ if (q.type === 'file') {
197
+ jsonProperties[key] = {
198
+ type: 'object',
199
+ properties: {
200
+ fileId: { type: 'string' },
201
+ buffer: { type: 'object' },
202
+ filename: { type: 'string' },
203
+ mimeType: { type: 'string' }
204
+ },
205
+ required: ['filename', 'mimeType']
206
+ };
207
+ } else if (q.type === 'choice') {
208
+ jsonProperties[key] = {
209
+ type: 'string'
210
+ };
211
+ if (q.choices && q.choices.length > 0) {
212
+ jsonProperties[key].enum = q.choices;
213
+ }
214
+ } else {
215
+ jsonProperties[key] = {
216
+ type: 'string'
217
+ };
218
+ if (q.type === 'date') {
219
+ jsonProperties[key].pattern = '^\\d{4}-\\d{2}-\\d{2}$';
220
+ }
221
+ }
222
+
223
+ if (q.required) {
224
+ jsonRequired.push(key);
225
+ }
226
+
227
+ // Mapping Schema building
228
+ mappingSchema[key] = {
229
+ label: q.label,
230
+ type: q.type
231
+ };
232
+ if (q.type === 'choice') {
233
+ mappingSchema[key].options = {
234
+ choices: q.choices || []
235
+ };
236
+ if (q.allowOther) {
237
+ mappingSchema[key].options.allowOther = true;
238
+ }
239
+ }
240
+ }
241
+
242
+ const finalJsonSchema = {
243
+ type: 'object',
244
+ properties: jsonProperties,
245
+ required: jsonRequired
246
+ };
247
+
248
+ // Print TypeScript output
249
+ console.log('// ==========================================');
250
+ console.log('// GENERATED SCHEMAS FOR GOOGLE FORM');
251
+ console.log('// ==========================================');
252
+ console.log('\nexport const JSON_SCHEMA = ' + JSON.stringify(finalJsonSchema, null, 2) + ';\n');
253
+ console.log('export const MAPPING_SCHEMA = ' + JSON.stringify(mappingSchema, null, 2) + ';\n');
254
+
255
+ } finally {
256
+ await context.close();
257
+ await browser.close();
258
+ }
259
+ }
260
+
261
+ main().catch((err) => {
262
+ console.error('❌ Error during form introspection:', err.message);
263
+ process.exit(1);
264
+ });
package/index.ts DELETED
@@ -1,2 +0,0 @@
1
- export * from './types';
2
- export * from './GoogleFormSubmitter';
File without changes