drupal6: ทำมอดูล Thai Search

มีโจทย์เรื่องต้องค้นข้อมูลใน Drupal เป็นภาษาไทยในไซต์ที่เป็นอินทราเน็ตให้ได้

ปัญหา

การค้นข้อมูลใน Drupal เขาทำเป็น Full Text Search โดยทำเป็น cron ในการจัดเรียงดัชนีข้อมูล เวลามาค้นก็จะหาได้รวดเร็ว
แต่ปัญหาคือ

  • ภาษาไทยใช้แทบไม่ได้เลย เพราะการทำ Full Text Search ต้องอาศัยการตัดคำเพื่อนำไปจัดเรียงเป็นดัชนี ซึ่งตอนนี้ของเรายังด้อยเรื่องนี้อยู่ โดยเฉพาะกับ php (แต่เริ่มมีคนทำบ้างแล้ว เช่น ที่ pecl (เดาว่าจะออกกับ php-5.3 ดูรายละเอียดที่ ICU+PHP=love และ agavi.org ที่ผมยังไม่ได้ศึกษา)
  • เนื่องจากเป็น Full Text Search อีกเหมือนกัน ที่การบางส่วนของคำ ใช้ไม่ได้ เช่น ไม่สามารถค้น "iffi" จากคำว่า "difficult" ได้ หรือไม่สามารถค้น "stand" จากคำว่า "understandable" ได้

สำหรับไซต์ที่เป็นอินเตอร์เน็ต คือข้อมูลออกสู่โลกภายนอก สามารถแก้ได้โดยใช้กูเกิล โดยพิมพ์ต่อท้ายคำค้นว่า site:example.com หรือสร้าง custom search engine เอง

สำหรับไซต์ที่เป็นอินทราเน็ต คือข้อมูลอยู่แต่ภายใน หรือไม่อนุญาตให้ crawler เข้ามาค้นข้อมูลในไซต์ ทางแก้คือลงมอดูล solr (ท่าทางจะลงยาก เป็นจาวา) หรือมอดูลอื่น (ที่อาจยุ่งพอกัน)

ทางออก

ทางออกคือทำเองแบบง่าย ๆ พอใช้งานได้ดีกว่า โดยพยายามทำคือ

  • ทำให้ง่ายที่สุด (เพราะทำยากไม่เป็น ;D)
  • ไม่ต้องสร้างตารางใหม่ เวลาอัปเกรดแล้วข้อมูลไม่รก
  • ให้ค้นบางส่วนของคำได้ จะได้เป็นตัวเสริมของมอดูล search จริง ๆ (ในอนาคตอันใกล้นี้ search.module จะต้องทำงานนี้ได้แน่ ๆ)
  • ไม่ไปยุ่งกับ Core Module

ดูตัวอย่างจาก page_example.module และตัวอย่างจาก Core Module ได้ออกมาดังนี้

ติดตั้ง

ตามขั้นตอนปกติคือ

  • ติดตั้งไว้ที่ site/all/modules/ ตั้งชื่อมอดูลว่า thaisearch
  • เปิดใช้งานจาก admin/build/modules
  • เปิดข้ออนุญาตจากผู้ใช้ amdin/user/permissions

การใช้งานและข้อจำกัด

เนื่องจากเป็นรุ่นแรก ทำพอใช้งานได้ จึงทุลักทุเลพอควร

  • ให้ใช้งานด้วยการพิมพ์ลงไปใน URL ตรง ๆ ว่า thaisearch/keywords
  • สามารถเว้นวรรคในคำค้นได้ แต่โปรแกรมจะตัดทิ้งหมด เหลือแค่คำค้นคำแรก แก้การงงของ query ที่ซ้อนหลายชั้น
  • ไปค้นที่ หัวเรื่องของ node เนื้อความของ node และเนื้อตวามของ comment เท่านั้น
  • ตอนเขียน พบว่าฟังก์ชั่น pager_query ซึ่งต้องใช้ในการแยกหน้า กลับมีปัญหากับ query GROUP BY เลยตัดทิ้งหมดเลย และจำกัดข้อมูลค้นแค่หน้าเดียว 50 รายการ
  • ยังปรับตั้งค่าอะไรไม่ได้เลย
  • เนื่องจากใช้วิธีการค้นข้อมูลแบบโง่ ๆ ที่สุด จึงไม่ควรใช้กับไซต์ขนาดใหญ่ ควรใช้ในงานอินทราเน็ตเท่านั้น

หวังว่าคงจะมีเวลาศึกษา SQL และ PHP เพิ่มเติมเพื่อนำมาปรับปรุงในรุ่นหน้า หรือรอท่านผู้ใจบุญปรับปรุงแล้วแจกจ่ายต่อไป

เริ่มปรุง

สร้างไฟล์ info
$ cd /var/www/drupal
$ mkdir -p sites/all/modules/thaisearch
$ cd sites/all/modules/thaisearch
$ vi thaisearch.info

; $Id$
name = Thai search
description = Search Thai words in node/comment.
core = 6.x

version = "6.0-rc2"

สร้างไฟล์ module
$ vi thaisearch.module

<?php
// $Id: thaisearch.module,v 1.13 2007/10/17 19:38:36 litwol Exp $
// wd's: modify to thaisearch.module
/**
 * @file
 * This is an example outlining how a module can be used to display a
 * custom page at a given URL.
 */

/**
 * Implementation of hook_help().
 *
 * Throughout Drupal, hook_help() is used to display help text at the top of
 * pages. Some other parts of Drupal pages get explanatory text from these hooks
 * as well. We use it here to illustrate how to add help text to the pages your
 * module defines.
 */
function thaisearch_help($path, $arg) {
  switch ($path) {
    case 'thaisearch':
      // Here is some help text for a custom page.
      return t('Search thai words. Type in URL: <code><strong>thaisearch/<em>WORD1 WORD2 ...</em></strong></code> separated by space.');
  }
}

/**
 * Implementation of hook_perm().
 *
 * Since the access to our new custom pages will be granted based on
 * special permissions, we need to define what those permissions are here.
 * This ensures that they are available to enable on the user role
 * administration pages.
 */
function thaisearch_perm() {
  return array('access thaisearch');
}
/**
 * Implementation of hook_menu().
 *
 * You must implement hook_menu() to emit items to place in the main menu.
 * This is a required step for modules wishing to display their own pages,
 * because the process of creating the links also tells Drupal what
 * callback function to use for a given URL. The menu items returned
 * here provide this information to the menu system.
 *
 * With the below menu definitions, URLs will be interpreted as follows:
 *
 * If the user accesses http://example.com/?q=foo, then the menu system
 * will first look for a menu item with that path. In this case it will
 * find a match, and execute thaisearch_foo().
 *
 * If the user accesses http://example.com/?q=bar, no match will be found,
 * and a 404 page will be displayed.
 *
 * If the user accesses http://example.com/?q=bar/baz, the menu system
 * will find a match and execute thaisearch_baz().
 *
 * If the user accesses http://example.com/?q=bar/baz/1/2, the menu system
 * will first look for bar/baz/1/2. Not finding a match, it will look for
 * bar/baz/1/%. Again not finding a match, it will look for bar/baz/%/2. Yet
 * again not finding a match, it will look for bar/baz/%/%. This time it finds
 * a match, and so will execute thaisearch_baz(1, 2). Note the parameters
 * being passed; this is a very useful technique.
 */
function thaisearch_menu() {

  // By using the MENU_CALLBACK type, we can register the callback for this
  // path but not have the item show up in the menu; the admin is not allowed
  // to enable the item in the menu, either.
  //
  // Notice that the 'page arguments' is an array of numbers. These will be
  // replaced with the corresponding parts of the menu path. In this case a 0
  // would be replaced by 'thaisearch', and 1 will be Thai words to search.
  // These will be passed as arguments to the thaisearch_thaisearch() function.
  $items['thaisearch/%'] = array(
    'title' => 'Thai Search',
    'page callback' => 'thaisearch_thaisearch',
    'page arguments' => array(1),
    'access arguments' => array('access thaisearch'), 
//    'type' => MENU_CALLBACK,
  );
 
  return $items;
}
 
/**
 * A more complex page callback that takes arguments.
 * 
 * The arguments are passed in from the page URL. The in our hook_menu
 * implementation we instructed the menu system to extract the last two
 * parameters of the path and pass them to this function as arguments.
 */
function thaisearch_thaisearch($words) {
  $max_item = 50;
  $keys = explode(" ", $words);
   
  $qnt = "SELECT 2 AS score, n.nid, 0 AS cid, n.title, n.body FROM {node_revisions} n WHERE n.title LIKE '%%%s%%' ";
  $qnb = "SELECT 1 AS score, n.nid, 0 AS cid, n.title, n.body FROM {node_revisions} n WHERE n.body LIKE '%%%s%%' ";
  $qc = "SELECT 1 AS score, c.nid, c.cid, n.title, c.comment AS body FROM {comments} c INNER JOIN {node_revisions} n ON n.nid = c.nid WHERE c.comment LIKE '%%%s%%' ";
  
  $q = 'SELECT SUM(score) AS score, nid, cid, title, body FROM ('.$qnt.' UNION ALL '.$qnb.' UNION ALL '.$qc.' ) t GROUP BY t.nid, t.cid, t.title, t.body ORDER BY score, nid DESC';
  
  $query = db_query_range($q, $keys[0], $keys[0], $keys[0], 0, $max_item);

  $sql_count = 'SELECT COUNT(*) AS num FROM ('.$q.') t';
  $count = db_fetch_object(db_query($sql_count,  $keys[0], $keys[0], $keys[0]));
  $found = $count->num;

  $return = thaisearch_help('thaisearch','').'<br />';
  $return .= t('Search words: <strong>'.$words.'</strong>, found: <strong>'.$found.'</strong><br />');

  $dlist = '';
  $max_words = 20;
  $words_between = 10;
  while ($links = db_fetch_object($query)) {
    // highlight words
    $ar = explode($keys[0], $links->body);
    if ($ar[0]) {
      $len_left = drupal_strlen($ar[0]);
      $ar[0] = ($max_words > $len_left) ? $ar[0] : '...'.drupal_substr($ar[0], $len_left-$max_words, $max_words);
    }
    for ($i = 1; $i < count($ar); $i++) {
      $len_right = drupal_strlen($ar[$i]);
      if ($i == count($ar)-1) {
        $ar[$i] = ($max_words > $len_right) ? $ar[$i] : drupal_substr($ar[$i], 0, $max_words).'...';
      } else {
        $ar[$i] = ($max_words > $len_right) ? $ar[$i] : drupal_substr($ar[$i], 0, $words_between).'...'.drupal_substr($ar[$i], $len_right-$word_between, $word_between);
      }
    }

    if ($links->cid != 0) {
      $dlist .= '<dt>'.l($links->title, 'node/'.$links->nid, array('fragment' => 'comment-'.$links->cid)).'</dt>';
    } else {
      $dlist .= '<dt>'.l($links->title, 'node/'.$links->nid).'</dt>';
    }
    $dlist .= '<dd>'.implode('<strong>'.$keys[0].'</strong>', $ar).'</dd><br />';
  }
  
  if ($dlist) {
    $return .= theme('box', t('Search results'), '<br />'.$dlist, 10, 0);
  } else {
    $return .= theme('box', t('Your search yielded no results'));
  }
  return $return;
} 
?>

ผลการใช้งาน

รันได้รวดเร็วดีพอควร

ต้องปรับปรุง

(รอผู้ใจบุญ)

  • ทำเป็นบล๊อกไว้พิมพ์คำค้นง่าย ๆ
  • เขียน SQL ดี ๆ ให้ใช้กับฟังก์ชั่น pager_query ได้
  • ใช้กับเนื้อความได้ทุกแบบ ไม่จำกัดแค่ node กับ comments
  • เขียนเพิ่มเรื่อง uppercase/lowercase/Capitalize
  • ค้นได้ทีละหลายคำค้น โดยมีการให้น้ำหนัก (คำแรกเยอะหน่อย) แล้วจับมารวมกัน พอเรียงแบบ DESC มันน่าจะขึ้นหัวข้อที่เราต้องการที่สุดไว้ต้น ๆ คล้าย ๆ กูเกิล

อ้างอิง

เพื่อความสุขสวัสดี อย่าลืมรัน update.php ด้วย ไม่งั้นอาจมีปัญหาเรื่อง HTTP request failed

Topic: