D
P
0

WordPress & PHP

Import WXR Gagal `PCDATA invalid Char value 18` dan `Sequence ']]>' not allowed`? Sanitasi Byte XML Sebelum Parse

31 Juli 2026·5 menit baca
Import WXR Gagal `PCDATA invalid Char value 18` dan `Sequence ']]>' not allowed`? Sanitasi Byte XML Sebelum Parse

Migrasi sebuah situs klien dari WordPress lama ke instance baru kelihatannya rutin: ambil file export WXR, jalankan importer, tunggu. File export-nya 10MB, berisi 1758 item campuran post, page, dan attachment. Saya unggah, klik jalankan, dan importer mati di detik pertama. Bukan timeout, bukan layar putih, tapi parser XML langsung throw sebelum satu post pun sempat masuk.

Log-nya penuh baris merah dari libxml:

PCDATA invalid Char value 18
PCDATA invalid Char value 27
Sequence ']]>' not allowed in content

Dua keluarga error yang berbeda dalam satu file. Yang pertama muncul berulang untuk beberapa char value di rentang 18 sampai 29. Yang kedua cuma sekali, tapi satu kejadian itu cukup untuk menjatuhkan seluruh parse.

Dead end dulu

Refleks pertama saya salah arah. Saya kira file-nya korup saat transfer, jadi saya export ulang dari situs lama. Hasilnya sama persis. Saya buka file 10MB itu di editor dan cari secara visual: kelihatan normal, ribuan baris <item> yang rapi. Mata saya tidak akan pernah menemukan byte yang salah, karena masalahnya memang bukan sesuatu yang bisa dilihat. Saya juga sempat menaikkan memory_limit, mengira parser kehabisan memori. Bukan itu. Parser-nya justru sehat dan bekerja benar: dia menolak XML yang memang tidak valid menurut spesifikasi.

Kenapa parser menolak

Dua akar yang berbeda, dan keduanya lahir dari cara konten WordPress dibuat.

Pertama, byte kontrol. Editor WordPress menyimpan apa pun yang di-paste penulis, dan konten yang datang dari Word, PDF, atau terminal sering membawa karakter kontrol mentah di rentang 0x00 sampai 0x1F. XML 1.0 hanya mengizinkan tiga di antaranya di dalam konten: tab (0x09), line feed (0x0A), dan carriage return (0x0D). Sisanya ilegal, dan parser yang patuh seperti libxml di balik SimpleXML dan DOMDocument wajib menolaknya. Itulah PCDATA invalid Char value 18: sebuah byte 0x12, karakter kontrol, terselip entah dari mana di isi salah satu post.

Kedua, sequence ]]>. WXR membungkus isi post di dalam CDATA lewat <![CDATA[ ... ]]>. Aturannya, string ]]> hanya boleh muncul sebagai penutup CDATA. Tapi kalau ada penulis yang pernah nge-paste potongan kode atau teks yang kebetulan mengandung ]]> di tengah isi post, string itu menutup CDATA lebih awal, dan sisa konten jadi PCDATA liar. Parser melihat ]]> yang menganga di luar CDATA lalu throw Sequence ']]>' not allowed in content.

Sanitasi mentah sebelum parse

Kuncinya: bersihkan XML sebagai string byte dulu, sebelum diserahkan ke parser mana pun. Bukan menambal parser, tapi memberi makan parser sebuah file yang valid.

function wxr_sanitize( $xml ) {
    // 1. Buang BOM UTF-8 di depan file kalau ada.
    $xml = preg_replace( '/^\xEF\xBB\xBF/', '', $xml );
 
    // 2. Buang byte kontrol ilegal, sisakan tab, LF, CR.
    $xml = preg_replace( '/[\x00-\x08\x0B\x0C\x0E-\x1F]/', '', $xml );
 
    // 3. Escape ]]> liar: yang bukan penutup CDATA yang sah.
    //    Di WXR, penutup sah selalu langsung diikuti </tag.
    $xml = preg_replace( '/\]\]>(?!\s*<\/)/', ']]&gt;', $xml );
 
    return $xml;
}

Tiga langkah untuk tiga sumber masalah. BOM di depan file bikin parser bingung soal encoding, jadi dibuang lebih dulu. Regex kedua membuang persis byte yang dikeluhkan libxml sambil menyisakan tab, LF, dan CR yang legal. Regex ketiga adalah heuristik yang cocok dengan bentuk WXR: penutup CDATA yang sah di file ini selalu berbentuk ]]></tag>, jadi ]]> yang tidak diikuti </ hampir pasti sequence liar di dalam konten, dan saya ubah jadi entity aman ]]&gt;.

Setelah sanitasi saya bandingkan ukuran sebelum dan sesudah: dari 10MB, cuma 33 byte yang terbuang. Tiga puluh tiga byte tak terlihat yang menjatuhkan 1758 item. Parser langsung mulus dan semua post masuk.

Tembok kedua: 524 saat unduh attachment

Post masuk, tapi importer belum selesai. Masih ada 465 attachment yang harus diunduh dari situs lama dan didaftarkan ke media library, dan versi pertama saya melakukan semua itu dalam satu request. Sekitar seratus detik kemudian muncul halaman 524 A Timeout Occurred dari Cloudflare. Origin saya terlalu lama menjawab, dan proxy memutus koneksi di 100 detik. Menaikkan max_execution_time sia-sia: yang membunuh bukan PHP, tapi proxy di depannya.

Perbaikannya bukan menaikkan timeout, tapi mengubah bentuk pekerjaannya. Saya pecah importer jadi state machine ber-fase dengan progres disimpan di WP options, dan yang paling menentukan adalah kill-switch berbasis waktu. Alih-alih percaya pada jumlah item tetap, tiap request memasang anggaran 50 detik dan berhenti sendiri jauh sebelum Cloudflare turun tangan.

define( 'WXR_TIME_BUDGET', 50 ); // detik, separuh batas Cloudflare
 
function wxr_run_phase() {
    $start = microtime( true );
    $state = get_option( 'wxr_state', array( 'phase' => 'attachments', 'offset' => 0 ) );
 
    foreach ( wxr_pending_attachments( $state['offset'] ) as $item ) {
        // Berhenti sebelum proxy membunuh kita.
        if ( microtime( true ) - $start > WXR_TIME_BUDGET ) {
            break;
        }
        wxr_sideload_attachment( $item );
        $state['offset']++;
        update_option( 'wxr_state', $state, false );
    }
 
    return $state['offset'] < wxr_total_attachments();
}

Yang membedakan dari batch berukuran tetap: kill-switch ini mengukur waktu nyata, bukan menebak berapa item yang muat. Satu attachment 40MB dan satu attachment 4KB butuh waktu jauh berbeda, dan anggaran waktu menyesuaikan sendiri. State disimpan setelah tiap item, jadi kalau ada yang mati di tengah, request berikutnya lanjut dari offset terakhir tanpa mengulang.

Antar batch saya tidak pakai JavaScript atau cron. Cukup meta-refresh HTML yang memanggil ulang URL importer sampai fase selesai.

if ( wxr_run_phase() ) {
    $next = admin_url( 'admin.php?page=wxr-import&resume=1' );
    echo '<meta http-equiv="refresh" content="1;url=' . esc_url( $next ) . '">';
    exit;
}

Totalnya sekitar 22 kali page-load otomatis, masing-masing selesai dalam belasan detik, dan 465 attachment masuk tanpa satu pun 524 lagi.

Checklist

  • XML dari export pihak ketiga jangan pernah dianggap valid. Sanitasi sebagai byte string dulu, baru parse.
  • PCDATA invalid Char value N berarti ada byte kontrol ilegal. Buang 0x00-0x08, 0x0B, 0x0C, 0x0E-0x1F, sisakan tab, LF, CR.
  • Sequence ']]>' not allowed berarti ada ]]> liar di konten. Di WXR, escape ]]> yang tidak diikuti </ jadi ]]&gt;.
  • Bandingkan ukuran file sebelum dan sesudah sanitasi. 33 byte dari 10MB cukup untuk menjatuhkan seluruh import.
  • Untuk unduh massal di belakang Cloudflare, pakai kill-switch berbasis waktu (mis. 50 detik), bukan jumlah item tetap. Origin yang lambat selalu kalah dari batas 100 detik proxy.
  • Simpan state setelah tiap item dan resume lewat meta-refresh. Tanpa JS, tanpa cron.