Repositori ini di arsipkan karena sudah ada sistem yang baru yaitu enpitsu yang mempermudah proses login dengan hanya satu token juga mempermudah proses koreksi dan export nilai.
Ini adalah penjelasan atas konsep kartu ujian digital yang dapat memudahkan siswa tanpa perlu repot memasukkan NPSN (Nomor Pokok Sekolah Nasional), Username, dan Password. Nantinya jadwal dan token dapat langsung digunakan tanpa perlu mengetikkannya, tetapi tidak akan menggantikan input secara manual ketika token memiliki kesalahan.
Sebelumnya, saya berterima kasih kepada pihak sekolah yang sudah bergerak dengan menggunakan aplikasi sebagai sarana ulangan dan tidak menggunakan kertas sebagai upaya mengurangi dampak kerusakan lingkungan. Disini saya memberikan saran dalam sebuah konsep, berikan saya kritik dan saran serta maafkan jika ada kesalahan dalam bertutur kata.
Dalam kata lain, saya memberikan saran supaya kita lebih paperless dalam melaksanakan ujian.
Alasan pertama, karena ini merupakan sebuah aplikasi android yang notabene bisa mengakses file android dengan leluasa (yang terbatas ketika menggunakan web), alangkah baiknya proses mengetikan identitas siswa dan token dapat dipersingkat.
Alasan kedua, mungkin ini karena kurang teliti, ada siswi kelas 11 yang seharusnya mengerjakan soal kelas 11 malah mengerjakan soal kelas 10 karena tidak sadar telah memasukan kode yang pelajarannya mirip dengan kelas 10. Ya, memang keteledoran dia tetapi ini seharusnya bisa di minimalisir dengan kode yang sudah ditanam langsung ke kartu yang dibaca aplikasi.
Too Long; Didn't Read. Jika tidak ingin membaca terlalu panjang, ini adalah ringkasan penjelasan dengan menggunakan diagram.
Pertama-tama puppeteer mengunjungi halaman kartu.
Kemudian puppeteer melakukan web scraping, mengambil data yang diperlukan. Mekanisme yang lebih jelas sudah diterangkan di bagian Melakukan Ekstraksi Data.
Masih didalam fungsi puppeteer, scraping yang dilakukan oleh puppeteer akan menghasilkan file pdf asli, file pdf yang sudah disusupi data json, dan data mentahan. Teknik penyusupan data json ini menggunakan Buffer.concat
bawaan dari NodeJS.
Secara teknis, aplikasi android bisa membaca file dan membaca buffer. Buffer yang sudah dibaca akan di ubah menjadi ascii
dan dipisahkan dari EOF
(End Of File) PDF dan mendapatkan string JSON. String JSON yang sudah dibaca akan digunakan oleh aplikasi android itu kedepannya.
Sebelum masuk ke konsep utama, saya sebagai siswa mendapatkan kartu dengan memasukan U-PIN yang diberikan dengan cara verifikasi melalui Whatsapp. Pesan yang ada di Whatsapp akan mengarahkan siswa ke website https://kartuujian.sman12-bekasi.sch.id/ dan mengisikan U-PIN yang tertera.
Setelah tombol Login
ditekan, akan diarahkan ke halaman kartu yang sebenarnya. Kira-kira tampilannya akan jadi seperti ini.
Ini adalah bagian analisis yang akan memberikan penjelasan bagaimana halaman web kartu bekerja agar memberikan gambaran bagaimana nantinya bisa membuat kartu digital.
Saya melakukan inspect element ke tombol Cetak
yang ada, dan ternyata yang dilakukan adalah menjalankan window.print()
setelah tombol di tekan. Setelahnya akan dimunculkan popup atau dialogue yang menginformasikan ke pengguna untuk menyimpan atau langsung mengeprintnya.
Jika diperhatikan, tombol Cetak
hilang jika sudah di print/didownload dikarenakan ada class .no-print
yang cssnya tidak menampilkan pada saat media print
yang artinya dalam mode printing.
Jika dilihat dari URL yang ada di address bar, website dibuat dengan menggunakan php. Hal ini menandakan terdapat sebuah backend dan sebuah database yang menyimpan nama dan identitas siswa beserta jadwal juga token.
https://kartuujian.sman12-bekasi.sch.id/cetakskl.php?nisn=<U-PIN>
Data akan ditampilkan sesuai apa yang dimasukan sebagai U-PIN
oleh pengguna. Artinya website ini memiliki template yang sama dan dapat digunakan untuk scraping dan dapat diambil datanya sebagai data yang bisa digunakan sebagai kartu digital.
Cara yang paling mudah adalah menggunakan teknik scraping dari halaman website yang ada. Setelah menyelidiki dan melakukan percobaan, ini teknik yang bisa dilakukan untuk mengambil otomatis yang nantinya bisa di otomasi menggunakan puppeteer.
Yang pertama kali saya lakukan adalah mengambil elemen yang merupakan nomor dari kumpulan hari yang ada. Hal ini diambil karena elemen ini memiliki karakteristik khusus yaitu mengisi 4 baris sekaligus (4 rowspan).
Jadi, elemen ini bisa ditampung ke dalam variabel jadwalRef
.
const jadwalRef = [...document.querySelectorAll('td[rowspan="4"]')];
Setelah menampung variabel yang ada, saya melakukan mapping untuk mendapatkan mata pelajaran, token, dan waktu yang sesungguhnya. Untuk mendapatkannya, hal yang paling mungkin adalah melakukan while loop
yang dapat dihentikan jika sudah mencapai batas tertentu.
Hal yang dilakukan selanjutnya adalah mengecek parent parentElement
dan mengecek elemen sesudahnya atau nextElementSibling
apakah tidak ada elemen selanjutnya itu sebuah 'td[rowspan="4"]'
, jika pernyataan itu valid maka di hentikanlah while loop
dan keluar melanjutkan proses eksekusi kode.
Jika pernyataan itu tidak valid maka kita bisa melakukan pengecekan apakah mata pelajaran bukan merupakan -
. Dengan aman, kita bisa mengambil mata pelajaran, token dan waktu ulangan.
Untuk mendapatkan informasi tentang tanggal dan hari, kita cukup manfaatkan saja parent element yang terdapat didalam elemen td
.
Kira-kira kode akan terlihat seperti ini. Penjelasan tambahan tertera pada kode dibawah.
const jadwalRef = [...document.querySelectorAll('td[rowspan="4"]')];
const jadwal = jadwalRef.map((j) => {
let data = [];
let currentElement;
const parent = j.parentElement;
while (true) {
// Mengambil element selanjutnya
const next =
data.length < 1
? parent.nextElementSibling
: currentElement.nextElementSibling;
// Apakah elemen selanjutnya tidak ada
// atau elemen selanjutnya merupakan nomor tabel
if (!next || next.querySelector('td[rowspan="4"]')) break;
// Apakah elemen selanjutnya tidak memiliki string "-"
if (!next.querySelector("td").innerText.includes("-")) {
const mapelElement = next.querySelector("td");
const tokenElement = next.querySelector("td:nth-child(2)");
const waktuElement = next.querySelector("td:nth-child(3)");
// Mereplace string 1-9<spasi> dengan ''
const pelajaran = mapelElement.innerText.replace(/[0-9].\s/, "");
const tokenStr = tokenElement.innerText;
// Terdapat token yang memiliki beragam opsi.
// Supaya tetap konsisten, tipe data akan tetap
// berupa array of string
const token = tokenStr.includes("/") ? tokenStr.split(" / ") : [tokenStr];
// Mereplace – (en dash) dengan - (hypen)
// Karena menyebabkan bug ketika diubah
// menjadi string json yang disisipkan
// ke dalam file
const waktu = waktuElement.innerText.replace("–", "-");
data.push({
pelajaran,
token,
waktu,
});
}
currentElement = next;
}
const hariElement = parent.querySelector("th");
// Memisahkan antara hari dan tanggal
const [hari, tanggal] = hariElement.innerText.split(", ");
return {
hari,
tanggal,
mapel: data,
};
});
Bagian ini merupakan tabel yang memiliki 800px dan tabel kedua yang ada di jenisnya ('table[width="800"]:nth-of-type(2)'
). Semua text yang ada dicetak tebal menggunakan tag b
, jadi bisa di simpulkan bahwa elemen bisa diambil menggunakan querySelectorAll
dengan parameter 'table[width="800"]:nth-of-type(2) td b'
.
Jika kita gabungkan dengan kode yang sebelumnya, hasilnya akan jadi seperti ini. Penjelasan lebih sudah tercantum dalam kode dibawah ini.
const jadwalRef = [...document.querySelectorAll('td[rowspan="4"]')];
const [nama, kelas, nomorPeserta, npsn, username, password] = [
...document.querySelectorAll('table[width="800"]:nth-of-type(2) td b'),
]
// Di filter apakah merupakan 6 elemen yang dimaksud
.filter((el) => el.parentElement.getAttribute("colspan") === "2")
// Akan terdapat spasi, oleh karenya di trim
// untuk menghilangkan spasi
.map((el) => el.innerText.trim());
const jadwal = jadwalRef.map((j) => {
let data = [];
let currentElement;
const parent = j.parentElement;
while (true) {
// Mengambil element selanjutnya
const next =
data.length < 1
? parent.nextElementSibling
: currentElement.nextElementSibling;
// Apakah elemen selanjutnya tidak ada
// atau elemen selanjutnya merupakan nomor tabel
if (!next || next.querySelector('td[rowspan="4"]')) break;
// Apakah elemen selanjutnya tidak memiliki string "-"
if (!next.querySelector("td").innerText.includes("-")) {
const mapelElement = next.querySelector("td");
const tokenElement = next.querySelector("td:nth-child(2)");
const waktuElement = next.querySelector("td:nth-child(3)");
// Mereplace string 1-9<spasi> dengan ''
const pelajaran = mapelElement.innerText.replace(/[0-9].\s/, "");
const tokenStr = tokenElement.innerText;
// Terdapat token yang memiliki beragam opsi.
// Supaya tetap konsisten, tipe data akan tetap
// berupa array of string
const token = tokenStr.includes("/") ? tokenStr.split(" / ") : [tokenStr];
// Mereplace – (en dash) dengan - (hypen)
// Karena menyebabkan bug ketika diubah
// menjadi string json yang disisipkan
// ke dalam file
const waktu = waktuElement.innerText.replace("–", "-");
data.push({
pelajaran,
token,
waktu,
});
}
currentElement = next;
}
const hariElement = parent.querySelector("th");
// Memisahkan antara hari dan tanggal
const [hari, tanggal] = hariElement.innerText.split(", ");
return {
hari,
tanggal,
mapel: data,
};
});
// Ini adalah data yang dapat digunakan
const finalData = {
user: {
nama,
kelas,
nomor_peserta: nomorPeserta,
npsn,
username,
password,
},
jadwal,
};
Kode diatas sudah menunjukkan cara bagaimana dokumen bisa dibuat, tapi supaya dapat memberikan gambaran struktur data yang lengkap, berikut ini merupakan file typescript declaration hasil scraping diatas.
type UserType = {
nama: string;
kelas: string;
nomor_peserta: string;
npsn: string;
username: string;
password: string;
};
type MapelType = {
pelajaran: string;
token: string[];
waktu: string;
};
type jadwalType = {
hari: string;
tanggal: string;
mapel: MapelType[];
};
export type IDigitalCard = {
user: UserType;
jadwal: jadwalType[];
};
Terdapat sebuah contoh scraper yang menjadi kode dasar yang mungkin kedepannya dapat digunakan.
Jalankan perintah ini Command Line.
# HTTPS
git clone https://github.com/reacto11mecha/konsep-kartu-ujian-digital.git
# SSH
git clone git@github.com:reacto11mecha/konsep-kartu-ujian-digital.git
Anda ke root directory project dan menginstall package yang diperlukan.
npm install
# atau menggunakan pnpm
pnpm install
Scraper dapat dijalankan dengan perintah berikut.
npm run scrape <U-PIN>
# atau
pnpm scrape <U-PIN>
Jika berhasil diambil, akan terbentuk folder result didalam folder result
yang ada di 001-ekstraksi-data. Akan ada 3 file yang terbentuk, file pdf asli, yang sudah terdapat data kartu, dan data mentahan kartu.
Singkatnya dengan snippet dibawah ini, kita dapat membaca mengetahui informasi yang tersembunyi dalam file.
const fs = require("fs");
const pdfBuffer = fs.readFileSync("./to_pdf_file_path.pdf");
const asciiString = fileBuffer.toString("ascii");
const str = asciiString.substring(asciiString.indexOf("%%EOF") + 5);
// Isinya ada disini
const message = str.trim();
Jalankan perintah dibawah ini untuk membaca file yang sudah ada di folder result folder ekstraksi data. Di folder 02-membaca-kartu
juga terdapat contoh pembacaan informasi rahasia dengan menggunakan bahasa JavaScript.
npm run read
# atau
pnpm read
Untuk contoh file java juga terdapat di 02-membaca-kartu/ContohMembacaFile.java
jika ingin mendapatkan gambaran bagaimana caranya mendapatkan data kartu digital di Java.
Jika ingin benar-benar memastikan kartu itu dibuat oleh pihak sekolah, kartu harus memiliki tanda tangan elektronik menggunakan GnuPG (Gnu Privacy Guard) sebagai software penjamin dan bisa diverifikasi dalam aplikasi. Hal ini mengurangi kompleksitas aplikasi yang harus memvalidasi data json yang telah dimasukkan ke dalam file pdf.
Untuk menandatangani file
gpg --output <file_asli+extensi>.sig --detach-sig <file_asli+extensi>
Untuk memverifikasi keaslian file
gpg --verify <file_signaturenya> <file_aslinya>
Cara diatas merupakan cara manual, terdapat banyak wraper yang sudah ada untuk menangani gpg di banyak bahasa pemrograman. Seperti Bounty Castle dalam bahasa pemrograman Java dan OpenPGP.js di bahasa pemrograman JavaScript.
Kita bisa menggunakan puppeteer-cluster
untuk mempermudah mengambil data dalam jumlah yang banyak dan dapat diproses menjadi kartu digital secara langsung. Library ini dapat dikombinasikan dengan express
sebagai web server. Dibawah ini merupakan snippet penggunannya tanpa web server.
const { Cluster } = require("puppeteer-cluster");
(async () => {
const scraperCluster = await Cluster.launch({
concurrency: Cluster.CONCURRENCY_CONTEXT,
maxConcurrency: 2,
});
scraperCluster.task(async ({ page, data: { uPIN } }) => {
await page.goto(
`https://kartuujian.sman12-bekasi.sch.id/cetakskl.php?nisn=${uPIN}`,
{
waitUntil: "networkidle2",
}
);
const table = await page.$$("table");
if (table.length < 1)
throw new Error(`Tidak ada kartu dengan U-PIN: ${uPIN}`);
await page.emulateMediaType("print");
const pdfBuffer = await page.pdf({
format: "A4",
scale: 0.85,
});
const dataKartuDigital = await page.evaluate(() => {
const jadwalRef = [...document.querySelectorAll('td[rowspan="4"]')];
const [nama, kelas, nomorPeserta, npsn, username, password] = [
...document.querySelectorAll('table[width="800"]:nth-of-type(2) td b'),
]
.filter((el) => el.parentElement.getAttribute("colspan") === "2")
.map((el) => el.innerText.trim());
const jadwal = jadwalRef.map((j) => {
let data = [];
let currentElement;
const parent = j.parentElement;
while (true) {
const next =
data.length < 1
? parent.nextElementSibling
: currentElement.nextElementSibling;
if (!next || next.querySelector('td[rowspan="4"]')) break;
if (!next.querySelector("td").innerText.includes("-")) {
const mapelElement = next.querySelector("td");
const tokenElement = next.querySelector("td:nth-child(2)");
const waktuElement = next.querySelector("td:nth-child(3)");
const pelajaran = mapelElement.innerText.replace(/[0-9].\s/, "");
const tokenStr = tokenElement.innerText;
const token = tokenStr.includes("/")
? tokenStr.split(" / ")
: [tokenStr];
const waktu = waktuElement.innerText.replace("–", "-");
data.push({
pelajaran,
token,
waktu,
});
}
currentElement = next;
}
const hariElement = parent.querySelector("th");
const [hari, tanggal] = hariElement.innerText.split(", ");
return {
hari,
tanggal,
mapel: data,
};
});
return {
user: {
nama,
kelas,
nomor_peserta: nomorPeserta,
npsn,
username,
password,
},
jadwal,
};
});
return { json: dataKartuDigital, buffer: pdfBuffer };
});
// Cara pemakaian
try {
// Mengeksekusi scraper dengan U-PIN yang diberikan
const digitalCard = await scraperCluster.execute({
uPIN: req.params.upin,
});
console.log(digitalCard); // akan mengembalikan { json, buffer }
} catch (err) {
console.error(err.message);
}
})();
Contoh server yang terdapat scraper ada di folder 003-simple-scraper-server/ yang terdapat file server.js
. File tersebut bisa dijalankan menggunakan perintah
npm start
# atau
pnpm start
Kunjungi http://localhost:3000/ untuk melihat petunjuk penggunaan, karena sewaktu-waktu path dapat berubah.