Проблема: 1С есть, но она внутри корпоративного VPN. Преподаватели/специалисты хотят смотреть расписание замен с телефона, по дороге на работу. Без паролей, без VPN. Просто ссылку открыл — и увидел. А 1С в интернет выпускать нельзя — политика безопасности.
Решение: Архитектура с посредником. 1С кладёт файл в сетевую папку, маленький скрипт забирает его через VPN и выкладывает на публичный сайт. Всё автоматически, раз в час. Ниже — полный рабочий код.
📌 Шаг 1. Что сделать на стороне 1С
Договоритесь с 1С-ником. Текст для него:
"Сделай регламентное задание, которое раз в час сохраняет расписание замен в файл CSV
и кладёт его в сетевую папку \\server\share\raspisanie.csv"Пример формата CSV (важно для календаря):
Дата,Время,Кабинет,Преподаватель,Предмет,Примечание
2026-05-22,09:00,101,Иванов А.А.,Физика,замена
2026-05-22,11:00,203,Петрова Б.Б.,Математика,
2026-05-23,10:00,105,Сидоров В.В.,Информатика,перенос<Если 1С-ник скажет «не умею» — покажите ему этот код выгрузки (встроенный язык 1С):
Процедура ВыгрузитьРасписание() Экспорт
Таблица = Новый ТаблицаЗначений;
// ... заполнить таблицу из справочника ...
Путь = "\\server\share\raspisanie.csv";
Запись = Новый ЗаписьТекста(Путь, "UTF-8", ложь);
Запись.ЗаписатьСтроку("Дата,Время,Кабинет,Преподаватель,Предмет,Примечание");
Для каждого Стр Из Таблица Цикл
Запись.ЗаписатьСтроку(Стр.Дата + "," + Стр.Время + "," + Стр.Кабинет + "," + Стр.Преподаватель + "," + Стр.Предмет + "," + Стр.Примечание);
КонецЦикла;
Запись.Закрыть();
КонецПроцедуры🐍 Шаг 2. Скрипт-посредник (Python)
Этот скрипт запускается на любом компьютере/виртуалке, который умеет подключаться к VPN и имеет выход в интернет. Он: подключает VPN → копирует файл → отключает VPN → заливает на FTP.
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
import os
import shutil
import subprocess
import ftplib
import sys
from datetime import datetime
# ========== НАСТРОЙКИ (меняйте под себя) ==========
# VPN (Windows)
VPN_NAME = "Мой VPN" # Имя подключения в Windows
VPN_LOGIN = "vpn_login"
VPN_PASS = "vpn_password"
# Файлы и пути
SOURCE_FILE = r"\\192.168.1.100\share\raspisanie.csv"
TEMP_FILE = r"C:\temp\raspisanie.csv"
# FTP
FTP_HOST = "ftp.ваш-сайт.ru"
FTP_USER = "ftp_login"
FTP_PASS = "ftp_password"
FTP_PATH = "/public_html/schedule/"
# ===================================================
def log(msg):
print(f"[{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}] {msg}")
def vpn_connect():
log("Подключаю VPN...")
cmd = f'rasdial "{VPN_NAME}" {VPN_LOGIN} {VPN_PASS}'
result = subprocess.run(cmd, shell=True, capture_output=True, text=True)
if "connected" in result.stdout.lower():
log("VPN подключён")
return True
log(f"Ошибка: {result.stderr}")
return False
def vpn_disconnect():
log("Отключаю VPN...")
subprocess.run(f'rasdial "{VPN_NAME}" /disconnect', shell=True, capture_output=True)
def copy_file():
if not os.path.exists(SOURCE_FILE):
log(f"Файл не найден: {SOURCE_FILE}")
return False
os.makedirs(os.path.dirname(TEMP_FILE), exist_ok=True)
shutil.copy2(SOURCE_FILE, TEMP_FILE)
log(f"Скопирован: {TEMP_FILE} ({os.path.getsize(TEMP_FILE)} байт)")
return True
def upload_ftp():
try:
ftp = ftplib.FTP(FTP_HOST)
ftp.login(FTP_USER, FTP_PASS)
ftp.cwd(FTP_PATH)
with open(TEMP_FILE, 'rb') as f:
ftp.storbinary('STOR raspisanie.csv', f)
ftp.quit()
log("Загружен на FTP")
return True
except Exception as e:
log(f"Ошибка FTP: {e}")
return False
def main():
log("=== СТАРТ ===")
if not vpn_connect():
sys.exit(1)
success = copy_file()
vpn_disconnect()
if success:
upload_ftp()
log("=== ГОТОВО ===")
if __name__ == "__main__":
main()Как запустить:
- Установить Python (python.org)
- Сохранить скрипт, заменить настройки
- Проверить:
python upload_to_ftp.py - Добавить в Планировщик заданий Windows (Task Scheduler) с повторением каждый час
openvpn и crontab. В скрипте замените rasdial на вызов openvpn --config.
📅 Шаг 3. Календарь на сайте (HTML + JS)
Сохраните этот код как index.html на вашем хостинге. Он загружает CSV и показывает красивое расписание с группировкой по дням, кликабельными деталями и навигацией по неделям.
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Расписание замен</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Arial, sans-serif; background: #f5f7fa; padding: 20px; }
.container { max-width: 1300px; margin: 0 auto; }
h1 { font-size: 28px; margin-bottom: 10px; color: #2c3e50; }
.last-update { color: #7f8c8d; font-size: 14px; margin-bottom: 20px; padding-bottom: 10px; border-bottom: 1px solid #e0e0e0; }
.controls { margin-bottom: 25px; display: flex; gap: 15px; flex-wrap: wrap; align-items: center; }
button { background: #3498db; color: white; border: none; padding: 8px 20px; border-radius: 6px; cursor: pointer; font-size: 14px; }
button:hover { background: #2980b9; }
.week-nav { display: flex; gap: 10px; background: white; padding: 8px 15px; border-radius: 30px; box-shadow: 0 1px 3px rgba(0,0,0,0.1); }
.week-nav button { background: #ecf0f1; color: #2c3e50; padding: 5px 12px; }
.calendar { display: grid; grid-template-columns: repeat(auto-fill, minmax(350px, 1fr)); gap: 20px; }
.day-card { background: white; border-radius: 12px; box-shadow: 0 2px 8px rgba(0,0,0,0.1); overflow: hidden; }
.day-header { background: #2c3e50; color: white; padding: 15px; font-size: 18px; font-weight: bold; }
.day-header small { font-size: 13px; font-weight: normal; opacity: 0.8; }
.lessons-list { padding: 10px; }
.lesson-item { border-bottom: 1px solid #eee; padding: 12px; cursor: pointer; transition: background 0.2s; }
.lesson-item:hover { background: #f8f9fa; }
.lesson-time { font-weight: bold; color: #2980b9; font-size: 15px; margin-bottom: 5px; }
.lesson-room { color: #27ae60; font-size: 13px; margin-bottom: 3px; }
.lesson-teacher { color: #7f8c8d; font-size: 14px; }
.lesson-details { margin-top: 10px; padding: 10px; background: #ecf0f1; border-radius: 8px; font-size: 14px; display: none; }
.lesson-details.show { display: block; }
.empty-day { text-align: center; padding: 30px; color: #95a5a6; }
.loading { text-align: center; padding: 50px; color: #7f8c8d; }
@media (max-width: 750px) { .calendar { grid-template-columns: 1fr; } body { padding: 10px; } }
</style>
</head>
<body>
<div class="container">
<h1>📅 Расписание замен</h1>
<div class="last-update" id="lastUpdate">Загрузка...</div>
<div class="controls">
<button onclick="loadSchedule()">🔄 Обновить</button>
<div class="week-nav">
<button onclick="changeWeek(-1)">◀ Пред.</button>
<span style="padding: 0 10px;" id="weekRange">Текущая неделя</span>
<button onclick="changeWeek(1)">След. ▶</button>
</div>
</div>
<div id="calendarContainer" class="loading">Загрузка расписания...</div>
</div>
<script>
const CSV_URL = "raspisanie.csv";
const COL_DATE = 0, COL_TIME = 1, COL_ROOM = 2, COL_TEACHER = 3, COL_SUBJECT = 4, COL_NOTE = 5;
let allLessons = [];
let currentWeekOffset = 0;
function parseCSV(text) {
const lines = text.split(/\r?\n/);
const result = [];
for (let i = 1; i < lines.length; i++) {
const line = lines[i].trim();
if (!line) continue;
const row = [];
let current = "", inQuotes = false;
for (let ch of line) {
if (ch === '"') inQuotes = !inQuotes;
else if (ch === ',' && !inQuotes) { row.push(current.trim()); current = ""; }
else current += ch;
}
row.push(current.trim());
if (row.length >= 4) {
result.push({
date: row[COL_DATE] || "",
time: row[COL_TIME] || "",
room: row[COL_ROOM] || "",
teacher: row[COL_TEACHER] || "",
subject: row[COL_SUBJECT] || "",
note: row[COL_NOTE] || ""
});
}
}
return result;
}
async function loadSchedule() {
document.getElementById("calendarContainer").innerHTML = '<div class="loading">Загрузка...</div>';
try {
const res = await fetch(CSV_URL + "?t=" + Date.now());
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const text = await res.text();
allLessons = parseCSV(text);
document.getElementById("lastUpdate").innerHTML = `📅 Обновлено: ${new Date().toLocaleString("ru-RU")} | Записей: ${allLessons.length}`;
currentWeekOffset = 0;
renderCalendar();
} catch(e) {
document.getElementById("calendarContainer").innerHTML = `<div style="background:#e74c3c;color:white;padding:15px;border-radius:8px;">❌ Ошибка: ${e.message}</div>`;
}
}
function getWeekStart(offset = 0) {
const now = new Date();
now.setHours(0,0,0,0);
let day = now.getDay();
const mondayOffset = day === 0 ? -6 : 1 - day;
const monday = new Date(now);
monday.setDate(now.getDate() + mondayOffset + offset * 7);
return monday;
}
function formatDate(d) { return d.toLocaleDateString("ru-RU", { day: 'numeric', month: 'long' }); }
function isSameDate(dateStr, target) {
if (!dateStr) return false;
let y,m,d;
if (dateStr.includes('-')) [y,m,d] = dateStr.split('-');
else if (dateStr.includes('.')) [d,m,y] = dateStr.split('.');
else return false;
const lessonDate = new Date(parseInt(y), parseInt(m)-1, parseInt(d));
return lessonDate.toDateString() === target.toDateString();
}
function getLessonsForDate(date) {
return allLessons.filter(l => isSameDate(l.date, date)).sort((a,b) => a.time.localeCompare(b.time));
}
function changeWeek(delta) { currentWeekOffset += delta; renderCalendar(); }
function toggleDetails(id) {
const el = document.getElementById(`details-${id}`);
if (el) el.classList.toggle('show');
}
function renderCalendar() {
const weekStart = getWeekStart(currentWeekOffset);
const weekDays = [];
for (let i = 0; i < 7; i++) {
const d = new Date(weekStart);
d.setDate(weekStart.getDate() + i);
weekDays.push(d);
}
const startStr = formatDate(weekDays[0]);
const endStr = formatDate(weekDays[6]);
document.getElementById("weekRange").innerHTML = `${startStr} — ${endStr}`;
let html = '<div class="calendar">';
for (let day of weekDays) {
const lessons = getLessonsForDate(day);
const dayName = day.toLocaleDateString("ru-RU", { weekday: 'long' });
html += `<div class="day-card"><div class="day-header">${dayName}, ${formatDate(day)} <small>${lessons.length}</small></div><div class="lessons-list">`;
if (lessons.length === 0) html += '<div class="empty-day">📭 Нет занятий</div>';
else {
lessons.forEach((l, idx) => {
const uid = `${day.getTime()}_${idx}`;
html += `<div class="lesson-item" onclick="toggleDetails('${uid}')">
<div class="lesson-time">⏰ ${l.time || '—'}</div>
<div class="lesson-room">🏫 ${l.room || 'Кабинет не указан'}</div>
<div class="lesson-teacher">👨🏫 ${l.teacher || '—'}</div>
<div class="lesson-details" id="details-${uid}">${l.subject ? `<div>📖 Предмет: ${l.subject}</div>` : ''}${l.note ? `<div>📝 ${l.note}</div>` : ''}</div>
</div>`;
});
}
html += `</div></div>`;
}
html += `</div>`;
document.getElementById("calendarContainer").innerHTML = html;
}
loadSchedule();
</script>
</body>
</html>⚙️ Как собрать всё вместе
- 1 Договориться с 1С-ником — пусть настроит выгрузку CSV в сетевую папку раз в час.
- 2 Подготовить виртуалку или компьютер — с Python, VPN-клиентом и доступом к той самой сетевой папке.
- 3 Положить скрипт
upload_to_ftp.py, заменить настройки (VPN, FTP, пути). - 4 Настроить планировщик (Task Scheduler или cron) на запуск скрипта каждый час.
- 5 На хостинге создать папку, загрузить
index.htmlи пустойraspisanie.csvдля начала. - 6 Проверить — через час после первого запуска CSV должен обновиться на сайте.
❓ Частые проблемы и решения
- VPN не подключается из скрипта — проверьте имя подключения (должно точно совпадать). В Windows можно посмотреть командой
rasdial. - Файл не копируется из сетевой папки — права доступа. Убедитесь, что скрипт запускается от имени пользователя, у которого есть доступ к \\server\share\ .
- FTP не заливает — проверьте пароль, путь на сервере, активен ли FTP на хостинге.
- На сайте ошибка CORS — если CSV и HTML лежат в одной папке — проблем нет. Если на разных доменах — настройте CORS на сервере или положите файлы рядом.
- CSV в другой кодировке — если буквы «кракозябры», попросите 1С-ника сохранять в UTF-8, либо в скрипте Python добавьте
encoding='utf-8'при чтении.
🎯 Итог
Вы получили полностью рабочий pipeline:
- ✅ 1С выгружает CSV в сетевую папку (без доступа в интернет)
- ✅ Python-скрипт раз в час подключается к VPN, забирает файл, отключается и заливает на FTP
- ✅ Красивый календарь на сайте показывает расписание с группировкой по дням, кликабельными деталями и навигацией по неделям
Всё это работает на любом хостинге с поддержкой статических файлов. Никаких баз данных, бекендов, сложных настроек. Просто, надёжно, безопасно.
Удачи! Пусть ваши специалисты всегда знают, кто и когда их заменяет. 🚀
Комментарии
Пока нет комментариев. Будьте первым!