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.
@@ -0,0 +1,16 @@
1
+ {
2
+ "requestId": "1876174487",
3
+ "fullName": "1192130047",
4
+ "birthDate": "1354328376",
5
+ "grade": "1867801269",
6
+ "location": "1504377681",
7
+ "rate": "282114114",
8
+ "nds": "1347448341",
9
+ "exitDate": "1161802420",
10
+ "vacation": "1977793041",
11
+ "inStaff": "447126466",
12
+ "cvFile": "1595274093",
13
+ "company": "553187985",
14
+ "checklist": "1954006978",
15
+ "comment": "998553124"
16
+ }
package/.env.example ADDED
@@ -0,0 +1,4 @@
1
+ # Google Form Submitter Configuration
2
+
3
+ # CDP Web Socket endpoint for connecting to the Lightpanda browser engine
4
+ LIGHTPANDA_CDP_URL=ws://127.0.0.1:9222/
package/CLAUDE.md ADDED
@@ -0,0 +1,106 @@
1
+
2
+ Default to using Bun instead of Node.js.
3
+
4
+ - Use `bun <file>` instead of `node <file>` or `ts-node <file>`
5
+ - Use `bun test` instead of `jest` or `vitest`
6
+ - Use `bun build <file.html|file.ts|file.css>` instead of `webpack` or `esbuild`
7
+ - Use `bun install` instead of `npm install` or `yarn install` or `pnpm install`
8
+ - Use `bun run <script>` instead of `npm run <script>` or `yarn run <script>` or `pnpm run <script>`
9
+ - Use `bunx <package> <command>` instead of `npx <package> <command>`
10
+ - Bun automatically loads .env, so don't use dotenv.
11
+
12
+ ## APIs
13
+
14
+ - `Bun.serve()` supports WebSockets, HTTPS, and routes. Don't use `express`.
15
+ - `bun:sqlite` for SQLite. Don't use `better-sqlite3`.
16
+ - `Bun.redis` for Redis. Don't use `ioredis`.
17
+ - `Bun.sql` for Postgres. Don't use `pg` or `postgres.js`.
18
+ - `WebSocket` is built-in. Don't use `ws`.
19
+ - Prefer `Bun.file` over `node:fs`'s readFile/writeFile
20
+ - Bun.$`ls` instead of execa.
21
+
22
+ ## Testing
23
+
24
+ Use `bun test` to run tests.
25
+
26
+ ```ts#index.test.ts
27
+ import { test, expect } from "bun:test";
28
+
29
+ test("hello world", () => {
30
+ expect(1).toBe(1);
31
+ });
32
+ ```
33
+
34
+ ## Frontend
35
+
36
+ Use HTML imports with `Bun.serve()`. Don't use `vite`. HTML imports fully support React, CSS, Tailwind.
37
+
38
+ Server:
39
+
40
+ ```ts#index.ts
41
+ import index from "./index.html"
42
+
43
+ Bun.serve({
44
+ routes: {
45
+ "/": index,
46
+ "/api/users/:id": {
47
+ GET: (req) => {
48
+ return new Response(JSON.stringify({ id: req.params.id }));
49
+ },
50
+ },
51
+ },
52
+ // optional websocket support
53
+ websocket: {
54
+ open: (ws) => {
55
+ ws.send("Hello, world!");
56
+ },
57
+ message: (ws, message) => {
58
+ ws.send(message);
59
+ },
60
+ close: (ws) => {
61
+ // handle close
62
+ }
63
+ },
64
+ development: {
65
+ hmr: true,
66
+ console: true,
67
+ }
68
+ })
69
+ ```
70
+
71
+ HTML files can import .tsx, .jsx or .js files directly and Bun's bundler will transpile & bundle automatically. `<link>` tags can point to stylesheets and Bun's CSS bundler will bundle.
72
+
73
+ ```html#index.html
74
+ <html>
75
+ <body>
76
+ <h1>Hello, world!</h1>
77
+ <script type="module" src="./frontend.tsx"></script>
78
+ </body>
79
+ </html>
80
+ ```
81
+
82
+ With the following `frontend.tsx`:
83
+
84
+ ```tsx#frontend.tsx
85
+ import React from "react";
86
+ import { createRoot } from "react-dom/client";
87
+
88
+ // import .css files directly and it works
89
+ import './index.css';
90
+
91
+ const root = createRoot(document.body);
92
+
93
+ export default function Frontend() {
94
+ return <h1>Hello, world!</h1>;
95
+ }
96
+
97
+ root.render(<Frontend />);
98
+ ```
99
+
100
+ Then, run index.ts
101
+
102
+ ```sh
103
+ bun --hot ./index.ts
104
+ ```
105
+
106
+ For more information, read the Bun API docs in `node_modules/bun-types/docs/**.mdx`.
@@ -0,0 +1,319 @@
1
+ import { chromium } from 'playwright';
2
+ import Ajv from 'ajv';
3
+ import fs from 'fs/promises';
4
+ import path from 'path';
5
+ import type { FormSchemaMapping, SubmissionResult } from './types';
6
+
7
+ const ajv = new Ajv({ allErrors: true });
8
+
9
+ export class GoogleFormSubmitter {
10
+ private formUrl: string;
11
+ private jsonSchema: Record<string, any>;
12
+ private mappingSchema: FormSchemaMapping;
13
+ private cdpUrl: string;
14
+ private cacheFilePath: string;
15
+ private entryIdCache: Record<string, string> | null = null;
16
+ private validateFn: ReturnType<typeof ajv.compile>;
17
+ private cookies?: Array<any>;
18
+
19
+ constructor(options: {
20
+ formUrl: string;
21
+ jsonSchema: Record<string, any>;
22
+ mappingSchema: FormSchemaMapping;
23
+ cdpUrl?: string;
24
+ cacheDir?: string;
25
+ cookies?: Array<any>;
26
+ }) {
27
+ this.formUrl = options.formUrl;
28
+ this.jsonSchema = options.jsonSchema;
29
+ this.mappingSchema = options.mappingSchema;
30
+ this.cdpUrl = options.cdpUrl || 'ws://127.0.0.1:9222/';
31
+ this.cookies = options.cookies;
32
+
33
+ // Compile JSON Schema validation function
34
+ this.validateFn = ajv.compile(this.jsonSchema);
35
+
36
+ // Compute a hash or safe name for the cache file based on the form URL
37
+ const formIdMatch = this.formUrl.match(/\/d\/e\/([a-zA-Z0-9_-]+)/) || this.formUrl.match(/\/d\/([a-zA-Z0-9_-]+)/);
38
+ const formHash = formIdMatch ? formIdMatch[1] : Buffer.from(this.formUrl).toString('base64').substring(0, 16);
39
+
40
+ const cacheDir = options.cacheDir || path.join(process.cwd(), '.data');
41
+ this.cacheFilePath = path.join(cacheDir, `form-cache-${formHash}.json`);
42
+ }
43
+
44
+ /**
45
+ * Lazily loads and returns the resolved entryId mapping cache.
46
+ * If cache file exists, reads it. Otherwise, launches Lightpanda CDP,
47
+ * scans the DOM, caches it, and closes the browser.
48
+ */
49
+ private async getOrResolveEntryIds(): Promise<Record<string, string>> {
50
+ if (this.entryIdCache) {
51
+ return this.entryIdCache;
52
+ }
53
+
54
+ // 1. Try reading from cache file
55
+ try {
56
+ const content = await fs.readFile(this.cacheFilePath, 'utf-8');
57
+ this.entryIdCache = JSON.parse(content);
58
+ console.log(`[GoogleFormSubmitter] Loaded entry ID mapping cache from: ${this.cacheFilePath}`);
59
+ return this.entryIdCache!;
60
+ } catch {
61
+ // Cache file doesn't exist, proceed to resolve
62
+ console.log(`[GoogleFormSubmitter] Cache not found. Resolving entry IDs dynamically via Lightpanda...`);
63
+ }
64
+
65
+ // 2. Launch browser to inspect DOM
66
+ let browser;
67
+ let context;
68
+
69
+ try {
70
+ console.log(`[GoogleFormSubmitter] Connecting to CDP server at ${this.cdpUrl}...`);
71
+ browser = await chromium.connectOverCDP(this.cdpUrl, { timeout: 5000 });
72
+ context = await browser.newContext();
73
+ } catch (e: any) {
74
+ console.warn(`[GoogleFormSubmitter] CDP connection to Lightpanda failed: ${e.message}. Falling back to native headless Chromium...`);
75
+ browser = await chromium.launch({ headless: true });
76
+ context = await browser.newContext();
77
+ }
78
+ if (this.cookies && this.cookies.length > 0) {
79
+ await context.addCookies(this.cookies);
80
+ console.log(`[GoogleFormSubmitter] Injected ${this.cookies.length} cookies into browser context.`);
81
+ }
82
+ const page = await context.newPage();
83
+
84
+ try {
85
+ await page.goto(this.formUrl);
86
+ // Wait for fields to load
87
+ await page.locator('.Qr7Oae').first().waitFor({ state: 'attached', timeout: 15000 });
88
+
89
+ // Scan DOM for question containers
90
+ const domMappings = await page.evaluate(() => {
91
+ const result: Record<string, string> = {};
92
+ const containers = document.querySelectorAll('.Qr7Oae');
93
+
94
+ containers.forEach((container: any) => {
95
+ const headerEl = container.querySelector('[role="heading"], .M7eMe, .HoRgec');
96
+ if (!headerEl) return;
97
+
98
+ let labelText = (headerEl.textContent || '').trim();
99
+ // Remove trailing required asterisks and clean up whitespace
100
+ labelText = labelText.replace(/\s*\*$/, '').trim();
101
+
102
+ let entryId: string | null = null;
103
+
104
+ // Strategy A: Find hidden or text inputs with name entry.XXXX
105
+ const inputs = container.querySelectorAll('input[name^="entry."], textarea[name^="entry."]');
106
+ for (const input of inputs) {
107
+ const nameAttr = input.getAttribute('name');
108
+ if (nameAttr && nameAttr.startsWith('entry.')) {
109
+ entryId = nameAttr.replace('entry.', '').replace('_sentinel', '');
110
+ break;
111
+ }
112
+ }
113
+
114
+ // Strategy B: Parse from data-params if Strategy A fails
115
+ 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+)/);
125
+ if (match && match[1]) {
126
+ entryId = match[1];
127
+ }
128
+ }
129
+ }
130
+
131
+ if (labelText && entryId) {
132
+ result[labelText] = entryId;
133
+ }
134
+ });
135
+
136
+ return result;
137
+ });
138
+
139
+ // 3. Map mappingSchema properties to resolved entry IDs
140
+ const resolvedCache: Record<string, string> = {};
141
+ 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
+
159
+ if (matchedEntryId) {
160
+ resolvedCache[propName] = matchedEntryId;
161
+ } else {
162
+ console.warn(`[GoogleFormSubmitter] Warning: Could not resolve entryId for field "${propName}" (label: "${fieldMapping.label}")`);
163
+ }
164
+ }
165
+
166
+ // Save to cache file
167
+ await fs.mkdir(path.dirname(this.cacheFilePath), { recursive: true });
168
+ await fs.writeFile(this.cacheFilePath, JSON.stringify(resolvedCache, null, 2));
169
+ console.log(`[GoogleFormSubmitter] Saved resolved entry IDs cache to: ${this.cacheFilePath}`);
170
+
171
+ this.entryIdCache = resolvedCache;
172
+ return resolvedCache;
173
+ } finally {
174
+ await context.close();
175
+ await browser.close();
176
+ }
177
+ }
178
+
179
+ /**
180
+ * Validates input data and submits the form via direct HTTP POST.
181
+ */
182
+ public async submit(data: unknown): Promise<SubmissionResult> {
183
+ // 1. Validate data against JSON Schema
184
+ const isValid = this.validateFn(data);
185
+ if (!isValid) {
186
+ const errorMsg = this.validateFn.errors
187
+ ?.map((err) => `${err.instancePath} ${err.message}`)
188
+ .join(', ');
189
+ throw new Error(`Validation failed: ${errorMsg}`);
190
+ }
191
+
192
+ const typedData = data as Record<string, any>;
193
+
194
+ // 2. Get entryId mapping cache (resolves lazily if needed)
195
+ const entryIds = await this.getOrResolveEntryIds();
196
+
197
+ const fieldsMap: Record<string, string[]> = {};
198
+ const fieldsSubmitted: Record<string, string> = {};
199
+
200
+ for (const [propName, value] of Object.entries(typedData)) {
201
+ const entryId = entryIds[propName];
202
+ if (!entryId) {
203
+ continue;
204
+ }
205
+
206
+ const fieldMapping = this.mappingSchema[propName];
207
+ if (!fieldMapping) {
208
+ continue;
209
+ }
210
+
211
+ if (!fieldsMap[entryId]) {
212
+ fieldsMap[entryId] = [];
213
+ }
214
+
215
+ const currentList = fieldsMap[entryId];
216
+
217
+ 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;
223
+ }
224
+ } else if (fieldMapping.type === 'choice' && fieldMapping.allowOther && value) {
225
+ const isPredefined = fieldMapping.options?.choices?.includes(value);
226
+ if (isPredefined === false) {
227
+ currentList?.push('__other_option__');
228
+
229
+ const otherResponseKey = `${entryId}.other_option_response`;
230
+ let otherResponseList = fieldsMap[otherResponseKey];
231
+ if (!otherResponseList) {
232
+ otherResponseList = [];
233
+ fieldsMap[otherResponseKey] = otherResponseList;
234
+ }
235
+ otherResponseList.push(value);
236
+
237
+ fieldsSubmitted[fieldMapping.label] = `__other_option__ (${value})`;
238
+ } else {
239
+ currentList?.push(value);
240
+ fieldsSubmitted[fieldMapping.label] = value;
241
+ }
242
+ } else {
243
+ currentList?.push(String(value));
244
+ fieldsSubmitted[fieldMapping.label] = String(value);
245
+ }
246
+ }
247
+
248
+ // 4. Send direct HTTP POST inside the browser context to preserve cookies and hidden fields
249
+ let browser;
250
+ let context;
251
+
252
+ try {
253
+ browser = await chromium.connectOverCDP(this.cdpUrl, { timeout: 5000 });
254
+ context = await browser.newContext();
255
+ } catch {
256
+ browser = await chromium.launch({ headless: true });
257
+ context = await browser.newContext();
258
+ }
259
+
260
+ if (this.cookies && this.cookies.length > 0) {
261
+ await context.addCookies(this.cookies);
262
+ }
263
+ const page = await context.newPage();
264
+
265
+ try {
266
+ await page.goto(this.formUrl);
267
+ await page.locator('.Qr7Oae').first().waitFor({ state: 'attached', timeout: 15000 });
268
+
269
+ console.log(`[GoogleFormSubmitter] Sending HTTP POST request in page context...`);
270
+ const responseInfo = await page.evaluate(async ({ fieldsMap }) => {
271
+ const g = globalThis as any;
272
+ const form = g.document.querySelector('form');
273
+ if (!form) throw new Error('Could not find form element on page');
274
+
275
+ const searchParams = new URLSearchParams();
276
+ const formData = new g.FormData(form);
277
+
278
+ for (const [key, val] of formData.entries()) {
279
+ if (!key.startsWith('entry.')) {
280
+ searchParams.append(key, String(val));
281
+ }
282
+ }
283
+
284
+ for (const [entryId, values] of Object.entries(fieldsMap)) {
285
+ for (const val of values as string[]) {
286
+ searchParams.append(`entry.${entryId}`, val);
287
+ }
288
+ }
289
+
290
+ const actionUrl = form.action || g.location.href.replace('/viewform', '/formResponse');
291
+
292
+ const res = await g.fetch(actionUrl, {
293
+ method: 'POST',
294
+ body: searchParams,
295
+ headers: {
296
+ 'Content-Type': 'application/x-www-form-urlencoded'
297
+ }
298
+ });
299
+
300
+ return {
301
+ status: res.status,
302
+ text: await res.text()
303
+ };
304
+ }, { fieldsMap });
305
+
306
+ const isSuccess = responseInfo.status === 200 || responseInfo.text.includes('v-confirmation-msg') || responseInfo.text.toLowerCase().includes('confirmation');
307
+
308
+ return {
309
+ success: isSuccess,
310
+ statusCode: responseInfo.status,
311
+ message: isSuccess ? 'Form submitted successfully!' : 'Google Form rejected submission.',
312
+ fieldsSubmitted,
313
+ };
314
+ } finally {
315
+ await context.close();
316
+ await browser.close();
317
+ }
318
+ }
319
+ }
package/README.md ADDED
@@ -0,0 +1,115 @@
1
+ # Google Form Submitter
2
+
3
+ Пакет для строго типизированной, валидируемой и кэшируемой отправки данных во внешние (в том числе сторонние) Google Формы с использованием Lightpanda (CDP-соединение) или стандартного Chromium.
4
+
5
+ ## 🚀 Возможности
6
+
7
+ 1. **Строгая валидация (JSON Schema)**: Входные данные проверяются через стандартную JSON схему с использованием библиотеки `Ajv` перед любой сетевой отправкой.
8
+ 2. **Декларативное описание полей**: Позволяет сопоставлять программные свойства (например, `fullName`, `exitDate`) с русскими заголовками вопросов в Google Форме.
9
+ 3. **Ленивый парсинг DOM (Lazy Loading)**: При первой отправке модуль автоматически открывает форму в безголовом браузере, сканирует структуру вопросов, находит соответствующие им бэкенд `entryId` и сохраняет карту соответствий в локальный кэш-файл.
10
+ 4. **Сверхбыстрые повторные отправки**: После наполнения кэша последующие отправки выполняются мгновенно через прямой HTTP POST-запрос, эмулируя отправку формы без запуска графического браузера.
11
+ 5. **Авторизация (Куки)**: Поддерживает инжекцию файлов куки для прохождения авторизации в формах, требующих аккаунт Google (например, если в форме есть поле загрузки файла).
12
+ 6. **Поддержка опции «Другое»**: Автоматически распознает кастомные текстовые ответы для радио-кнопок/чекбоксов с выбором «Другое» и правильно их кодирует.
13
+
14
+ ---
15
+
16
+ ## 🛠️ Установка и Запуск
17
+
18
+ Перейдите в каталог `googleformsubmitter` и установите зависимости:
19
+ ```bash
20
+ cd googleformsubmitter
21
+ bun install
22
+ ```
23
+
24
+ ### Доступные скрипты:
25
+ * **Запуск тестов**:
26
+ ```bash
27
+ bun run test
28
+ ```
29
+ * **Проверка типов (tsgo)**:
30
+ ```bash
31
+ bun run validate
32
+ ```
33
+ * **Линтинг (oxlint)**:
34
+ ```bash
35
+ bun run lint
36
+ ```
37
+ * **Создание тестовой Google Формы**:
38
+ ```bash
39
+ bun run create-form
40
+ ```
41
+
42
+ ---
43
+
44
+ ## 📋 Описание Схемы данных
45
+
46
+ Отправка настраивается с помощью двух схем:
47
+
48
+ 1. **JSON Schema** — описывает типы данных, ограничения, обязательность и формат (например, регулярные выражения для дат).
49
+ 2. **Mapping Schema** — описывает связь между ключом в JSON схеме и человекочитаемым заголовком вопроса в Google Форме.
50
+
51
+ ### Пример декларативной конфигурации:
52
+
53
+ ```typescript
54
+ import { GoogleFormSubmitter } from './GoogleFormSubmitter';
55
+
56
+ // 1. Описание типов данных формы (JSON Schema)
57
+ const jsonSchema = {
58
+ type: 'object',
59
+ properties: {
60
+ fullName: { type: 'string' },
61
+ birthDate: { type: 'string', pattern: '^\\d{4}-\\d{2}-\\d{2}$' },
62
+ inStaff: { type: 'string' },
63
+ cvFile: {
64
+ type: 'object',
65
+ properties: {
66
+ fileId: { type: 'string' },
67
+ filename: { type: 'string' },
68
+ mimeType: { type: 'string' }
69
+ },
70
+ required: ['fileId', 'filename', 'mimeType']
71
+ }
72
+ },
73
+ required: ['fullName', 'birthDate', 'inStaff', 'cvFile']
74
+ };
75
+
76
+ // 2. Декларативный маппинг полей на заголовки вопросов в Google Форме
77
+ const mappingSchema = {
78
+ fullName: { label: 'ФИО (полностью)', type: 'text' },
79
+ birthDate: { label: 'Дата рождения', type: 'date' },
80
+ inStaff: { label: 'Находится ли специалист в штате?', type: 'choice', options: { choices: ['Да'], allowOther: true } },
81
+ cvFile: { label: 'CV (файл)', type: 'file' }
82
+ };
83
+
84
+ // 3. Инициализация сабмиттера
85
+ const submitter = new GoogleFormSubmitter({
86
+ formUrl: 'https://docs.google.com/forms/d/e/.../viewform',
87
+ jsonSchema,
88
+ mappingSchema,
89
+ cdpUrl: 'ws://127.0.0.1:9222/', // CDP-сервер Lightpanda
90
+ cookies: loadedCookies // Сессионные куки (необязательно)
91
+ });
92
+
93
+ // 4. Отправка данных
94
+ const result = await submitter.submit({
95
+ fullName: 'Иванов Иван',
96
+ birthDate: '1990-01-01',
97
+ inStaff: 'С рынка, на предоффере', // Будет передано в "Другое"
98
+ cvFile: {
99
+ fileId: '1BRVwwPlohb_4g7DUZ3HTz...',
100
+ filename: 'resume.docx',
101
+ mimeType: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'
102
+ }
103
+ });
104
+ ```
105
+
106
+ ---
107
+
108
+ ## 📂 Структура каталогов проекта
109
+
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` — шаблон конфигурационного файла.