<?php
/**
* SeekQuarry/Yioop --
* Open Source Pure PHP Search Engine, Crawler, and Indexer
*
* Copyright (C) 2009 - 2026 Chris Pollett chris@pollett.org
*
* LICENSE:
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* END LICENSE
*
* @author Chris Pollett chris@pollett.orgs
* @license https://www.gnu.org/licenses/ GPL3
* @link https://www.seekquarry.com/
* @copyright 2009 - 2026
* @filesource
*/
namespace seekquarry\yioop\models;
use seekquarry\yioop\configs as C;
use seekquarry\yioop\library as L;
/**
* Used to manage data related to the SCRAPER database table.
* This table is used to store web scrapers, a tool for scraping
* important content from pages which might have been generated
* by a content management system.
*
* @author Charles Bocage (changed CMS_DETECTORS to SCRAPER and
* simplified Chris Pollett)
*/
class ScraperModel extends Model
{
/**
* DEFAULT_SCRAPERS lists the scrapers a new Yioop database starts with,
* and that upgradeDatabaseVersion130 adds to an older one. Each row
* holds a scraper's name; its signature, an XPath that holds on pages
* made by the software the scraper is for; the XPath of the part of the
* page that holds its text; the XPaths of parts to delete from that
* text, one to a line; and fields to extract, one name=XPath to a line.
* A page is matched to the scraper whose signature holds on it, and that
* scraper's paths then pick out the page's text.
*/
const DEFAULT_SCRAPERS = [
["DRUPAL", "/html/head/*[contains(@href, '/sites/all/themes') or " .
"contains(@href, '/sites/default/files') or " .
"contains(@content, 'Drupal')]|" .
"//*[contains(@class, 'drupalorg-site-main')]",
"//*[@id='main']|//main",
"//*[contains(@class, 'd-animations-settings')]\n" .
"//*[contains(@class,'element-invisible')]\n" .
"//*[contains(@class,'visually-hidden')]\n" .
"//*[@id='skip-link']\n" .
"//*[contains(@class,'skip-link')]\n" .
"//*[contains(@class,'last-reply')]\n" .
"//*[contains(@class,'more-link')]\n" .
"//*[contains(@class,'breadbox')]\n" .
"//*[contains(@class,'topics')]\n" .
"//*[contains(@class,'posts')]\n" .
"//thead\n",
""],
["MEDIAWIKI", "//meta[contains(@content, 'MediaWiki')]",
"//main|//*[@id='content']",
"//*[contains(@class, 'cdx-button')]\n" .
"//*[contains(@class, 'vector-dropdown')]\n" .
"//*[contains(@class, 'vector-page-toolbar')]\n" .
"//*[contains(@class, 'vector-body-before-content')]\n" .
"//*[contains(@class, 'nmbox')]\n" .
"//*[contains(@class, 'hatnote')]\n" .
"//*[contains(@class, 'infobox')]\n" .
"//*[contains(@class, 'haudio')]\n" .
"//*[contains(@class, 'IPA')]\n" .
"//*[contains(@class, 'rt-commentedText')]\n" .
"//a[contains(., 'Full') and contains(., 'article')]\n",
""],
["VBULLETIN", "/html/head/*[contains(@href,'vbulletin')]",
"//main",
"//*[contains(@class, 'widget-tabs-nav')]\n" .
"//*[contains(@class, 'conversation-tool-wrapper')]\n" .
"//*[contains(@class, 'b-sharing-menu')]\n" .
"//*[contains(@class, 'pagenav-controls')]\n" .
"//*[contains(@class, 'pagenav-container')]\n" .
"//*[contains(@class, 'widget-header')]\n" .
"//*[contains(@class, 'b-post__footer')]\n" .
"//*[contains(@class, 'toolbar-filter-overlay')]\n" .
"//*[contains(@class,'forum-list-header')]\n" .
"//*[contains(@class,'toolbar-filter')]\n" .
"//*[contains(@class,'js-conversation-showmore')]\n" .
"//*[contains(@class,'filtered-by')]\n" .
"//*[contains(@class,'empty')]\n" .
"//*[contains(@class,'new-conversations-strip')]\n" .
"//*[contains(@class,'h-hide')]\n" .
"//*[contains(@class,'post-date')]\n" .
"//*[contains(@class,'cell-count')]\n" .
"//*[contains(@class,'topics-count')]\n" .
"//*[contains(@class,'posts-count')]\n" .
"//*[contains(@class,'list-footer')]",
""],
["VIDEO SITE",
"//meta[@property='og:type' and contains(@content, 'video')]",
"", "",
"IS_VIDEO=//meta[@property='og:type' and" .
" contains(@content, 'video')]/@content\n" .
"IS_VR=//meta[(@property='og:video:tag' ".
" (contains(@content, '360') or" .
" contains(@content, '180') or contains(@content, " .
"'VR'))]/@content\n" .
"SITE_NAME=//meta[@property='og:site_name']/@content\n" .
"HEIGHT=//meta[@property='og:video:height']/@content\n" .
"WIDTH=//meta[@property='og:video:width']/@content\n" .
"DURATION=//meta[@property='video:duration']/@content\n" .
"ALT_DURATION=//meta[@itemprop='duration']/@content\n".
"PUBDATE=//meta[@property='video:release_date']/@content\n" .
"ALT_PUBDATE=//meta[@itemprop='datePublished']/@content\n" .
"VIEW_COUNT=//meta[@itemprop='interactionCount']/@content\n" .
"THUMB_URL=//meta[@property='og:image']/@content"],
["WORDPRESS", "/html/head/*[contains(@href, 'wp-content') or ".
"contains(@href, 'wp-includes')]|" .
"//meta[@name='generator' and contains(@content, 'WordPress')]",
"//main|//*[contains(@class, 'wp-block-post-content')]|" .
"//div[starts-with(@id, 'post-') and " .
"'post-' = translate(@id, '0123456789', '') and " .
"string-length(@id) >4]|//div[contains(@class, " .
"'homepagewrapper')]|" .
"//div[contains(@class, 'homepage-row')]/parent::*|" .
"//div[contains(@class, 'archive-grid')]/parent::*",
"//*[@id='entry-comments']\n" .
"//*[contains(@class, 'sharedaddy')]\n" .
"//*[contains(@class, 'blog-subscribe')]\n" .
"//*[@id='entry-side']\n" .
"//*[contains(@class, 'signup')]\n" .
"//*[contains(@class, 'module-donate')]",
""],
["YIOOP", "//meta[@name='generator' and contains(@content,'Yioop')]",
"//div[contains(@class, 'body-container')]",
"//*[@id='message']\n" .
"//*[@id='help']\n" .
"//*[contains(@class, 'query-statistics')]\n" .
"//*[@id='suggest']\n" .
"//*[@id='spell']",
""],
["GHOST", "//meta[@name='generator' and contains(@content, 'Ghost')]",
"//article|//main",
"//*[contains(@class, 'gh-subscribe')]\n" .
"//*[contains(@class, 'gh-navigation')]\n" .
"//*[contains(@class, 'gh-footer')]\n" .
"//*[contains(@class, 'site-footer')]",
""],
["DISCOURSE",
"//meta[@name='generator' and contains(@content, 'Discourse')]",
"//*[@id='main-outlet']",
"//*[contains(@class, 'crawler-nav')]\n" .
"//*[contains(@class, 'crawler-linkback-list')]\n" .
"//*[@id='noscript-footer']",
""],
["BLOGGER",
"//meta[@name='generator' and contains(@content, 'Blogger')]",
"//*[contains(@class, 'post-body')]|//main",
"//*[contains(@class, 'post-share-buttons')]\n" .
"//*[contains(@class, 'blog-pager')]\n" .
"//*[@id='comments']",
""],
["JOOMLA",
"//meta[@name='generator' and contains(@content, 'Joomla')]",
"//*[@itemprop='articleBody']|//main",
"//*[contains(@class, 'pagenavigation')]\n" .
"//*[contains(@class, 'mod-breadcrumbs')]",
""],
["PHPBB", "//body[@id='phpbb']", "//*[@id='page-body']",
"//*[contains(@class, 'action-bar')]\n" .
"//*[contains(@class, 'jumpbox')]\n" .
"//*[contains(@class, 'post-buttons')]",
""],
["STACK EXCHANGE", "//meta[@property='og:site_name' and " .
"(contains(@content, 'Stack Overflow') or " .
"contains(@content, 'Stack Exchange'))]",
"//*[@id='mainbar']",
"//*[contains(@class, 'js-post-menu')]\n" .
"//*[contains(@class, 'js-voting-container')]\n" .
"//*[contains(@class, 'bottom-notice')]\n" .
"//*[@id='sidebar']",
""],
];
/**
* Controls which tables and the names of tables
* underlie the given model and should be used in a getRows call
*
* @param string $args it does not matter.
* @return string which table to use
*/
public function fromCallback($args = null)
{
return "SCRAPER";
}
/**
* Return the contents of the SCRAPER table
* @return array associative of rows with ID, NAME, SIGNATURE,
* PRIORITY, TEXT_PATH, DELETE_PATHS, EXTRACT_FIELDS, one for each scraper
*/
public function getAllScrapers()
{
$db = $this->db;
$sources = [];
$sql = "SELECT * FROM SCRAPER";
$result = $db->execute($sql);
if (!$result) {
return false;
}
while ($sources[] = $db->fetchArray($result)) {
}
return $sources;
}
/**
* Used to add a new scraper to Yioop
*
* @param string $name of scraper to add
* @param string $signature the xpath to query the html of a web
* document to see if a scrape rule should be applied
* @param int $priority to choose this scrape rule as opposed to other
* scrape rules
* @param string $text_path the xpath string used to find
* the main dom container for the important text in the html document
* @param string $delete_paths xpath strings of dom elements to be removed
* from the dom after the dom was restricted to just the $text_path
* content. These are used to remove extranenous info from the main
* text contents. Each xpath should be separated from each other by
* a new line.
* @param string $extract_fields a string of lines each line consists
* of a summary field name followed by = followed by
* an xpath. The intended meaning of such a line is to evaluate
* the xpath and create a new field in a document summary with
* either the concatenated, trimmed text value of the nodes of the
* results of the xpath
*/
public function add($name, $signature, $priority, $text_path,
$delete_paths, $extract_fields)
{
$db = $this->db;
$sql = "INSERT INTO SCRAPER(NAME, SIGNATURE, PRIORITY, TEXT_PATH,
DELETE_PATHS, EXTRACT_FIELDS) VALUES (?, ?, ?, ?, ?, ?)";
$db->execute($sql, [$name, $signature, $priority, $text_path,
$delete_paths, $extract_fields]);
}
/**
* Deletes the scraper with the provided id
*
* @param int $id of scraper to be deleted
*/
public function delete($id)
{
$sql = "DELETE FROM SCRAPER WHERE ID=?";
$this->db->execute($sql, [$id]);
}
/**
* Returns the scraper with the given id
* @param int $id of scraper to look up
* @return array associative array with ID, NAME, SIGNATURE,
* PRIORITY, TEXT_PATH, DELETE_PATHS, EXTRACT_FIELDS of a scraper
*/
public function get($id)
{
$db = $this->db;
$sql = "SELECT * FROM SCRAPER WHERE ID = ?";
$result = $db->execute($sql, [$id]);
if (!$result) {
return false;
}
$row = $db->fetchArray($result);
return $row;
}
/**
* Used to update the fields stored in a SCRAPER row according to
* an array holding new values
* @param array $scraper_info updated values for scraper
*/
public function update($scraper_info)
{
$id = $scraper_info['ID'];
unset($scraper_info['ID']);
$sql = "UPDATE SCRAPER SET ";
$comma = "";
$params = [];
foreach ($scraper_info as $field => $value) {
$sql .= "$comma $field=? ";
$comma = ",";
$params[] = $value;
}
$sql .= " WHERE ID=?";
$params[] = $id;
$this->db->execute($sql, $params);
}
}