Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 212
0.00% covered (danger)
0.00%
0 / 21
CRAP
0.00% covered (danger)
0.00%
0 / 1
GenerateSitemap
0.00% covered (danger)
0.00%
0 / 209
0.00% covered (danger)
0.00%
0 / 21
3306
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 34
0.00% covered (danger)
0.00%
0 / 1
2
 execute
0.00% covered (danger)
0.00%
0 / 20
0.00% covered (danger)
0.00%
0 / 1
20
 setNamespacePriorities
0.00% covered (danger)
0.00%
0 / 27
0.00% covered (danger)
0.00%
0 / 1
30
 generateNamespaces
0.00% covered (danger)
0.00%
0 / 16
0.00% covered (danger)
0.00%
0 / 1
30
 priority
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 guessPriority
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 getPageRes
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
2
 main
0.00% covered (danger)
0.00%
0 / 60
0.00% covered (danger)
0.00%
0 / 1
306
 open
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
12
 write
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
20
 close
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 sitemapFilename
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
6
 xmlHead
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 xmlSchema
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 openIndex
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 indexEntry
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
6
 closeIndex
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 openFile
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 fileEntry
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
2
 closeFile
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 generateLimit
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
2
1<?php
2/**
3 * Creates a sitemap for the site.
4 *
5 * Copyright © 2005, Ævar Arnfjörð Bjarmason, Jens Frank <jeluf@gmx.de> and
6 * Brooke Vibber <bvibber@wikimedia.org>
7 *
8 * This program is free software; you can redistribute it and/or modify
9 * it under the terms of the GNU General Public License as published by
10 * the Free Software Foundation; either version 2 of the License, or
11 * (at your option) any later version.
12 *
13 * This program is distributed in the hope that it will be useful,
14 * but WITHOUT ANY WARRANTY; without even the implied warranty of
15 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
16 * GNU General Public License for more details.
17 *
18 * You should have received a copy of the GNU General Public License along
19 * with this program; if not, write to the Free Software Foundation, Inc.,
20 * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
21 * http://www.gnu.org/copyleft/gpl.html
22 *
23 * @file
24 * @ingroup Maintenance
25 * @see http://www.sitemaps.org/
26 * @see http://www.sitemaps.org/schemas/sitemap/0.9/sitemap.xsd
27 */
28
29use MediaWiki\MainConfigNames;
30use MediaWiki\Title\Title;
31use MediaWiki\WikiMap\WikiMap;
32use Wikimedia\Rdbms\IDatabase;
33use Wikimedia\Rdbms\IResultWrapper;
34
35require_once __DIR__ . '/Maintenance.php';
36
37/**
38 * Maintenance script that generates a sitemap for the site.
39 *
40 * @ingroup Maintenance
41 */
42class GenerateSitemap extends Maintenance {
43    private const GS_MAIN = -2;
44    private const GS_TALK = -1;
45
46    /**
47     * The maximum amount of urls in a sitemap file
48     *
49     * @link http://www.sitemaps.org/schemas/sitemap/0.9/sitemap.xsd
50     *
51     * @var int
52     */
53    public $url_limit;
54
55    /**
56     * The maximum size of a sitemap file
57     *
58     * @link http://www.sitemaps.org/faq.php#faq_sitemap_size
59     *
60     * @var int
61     */
62    public $size_limit;
63
64    /**
65     * The path to prepend to the filename
66     *
67     * @var string
68     */
69    public $fspath;
70
71    /**
72     * The URL path to prepend to filenames in the index;
73     * should resolve to the same directory as $fspath.
74     *
75     * @var string
76     */
77    public $urlpath;
78
79    /**
80     * Whether or not to use compression
81     *
82     * @var bool
83     */
84    public $compress;
85
86    /**
87     * Whether or not to include redirection pages
88     *
89     * @var bool
90     */
91    public $skipRedirects;
92
93    /**
94     * The number of entries to save in each sitemap file
95     *
96     * @var array
97     */
98    public $limit = [];
99
100    /**
101     * Key => value entries of namespaces and their priorities
102     *
103     * @var array
104     */
105    public $priorities = [];
106
107    /**
108     * A one-dimensional array of namespaces in the wiki
109     *
110     * @var array
111     */
112    public $namespaces = [];
113
114    /**
115     * When this sitemap batch was generated
116     *
117     * @var string
118     */
119    public $timestamp;
120
121    /**
122     * A database replica DB object
123     *
124     * @var IDatabase
125     */
126    public $dbr;
127
128    /**
129     * A resource pointing to the sitemap index file
130     *
131     * @var resource
132     */
133    public $findex;
134
135    /**
136     * A resource pointing to a sitemap file
137     *
138     * @var resource|false
139     */
140    public $file;
141
142    /**
143     * Identifier to use in filenames, default $wgDBname
144     *
145     * @var string
146     */
147    private $identifier;
148
149    public function __construct() {
150        parent::__construct();
151        $this->addDescription( 'Creates a sitemap for the site' );
152        $this->addOption(
153            'fspath',
154            'The file system path to save to, e.g. /tmp/sitemap; defaults to current directory',
155            false,
156            true
157        );
158        $this->addOption(
159            'urlpath',
160            'The URL path corresponding to --fspath, prepended to filenames in the index; '
161                . 'defaults to an empty string',
162            false,
163            true
164        );
165        $this->addOption(
166            'compress',
167            'Compress the sitemap files, can take value yes|no, default yes',
168            false,
169            true
170        );
171        $this->addOption( 'skip-redirects', 'Do not include redirecting articles in the sitemap' );
172        $this->addOption(
173            'identifier',
174            'What site identifier to use for the wiki, defaults to $wgDBname',
175            false,
176            true
177        );
178        $this->addOption(
179            'namespaces',
180            'Only include pages in these namespaces in the sitemap, ' .
181            'defaults to the value of wgSitemapNamespaces if not defined.',
182            false, true, false, true
183        );
184    }
185
186    /**
187     * Execute
188     */
189    public function execute() {
190        $this->setNamespacePriorities();
191        $this->url_limit = 50000;
192        $this->size_limit = ( 2 ** 20 ) * 10;
193
194        # Create directory if needed
195        $fspath = $this->getOption( 'fspath', getcwd() );
196        if ( !wfMkdirParents( $fspath, null, __METHOD__ ) ) {
197            $this->fatalError( "Can not create directory $fspath." );
198        }
199
200        $dbDomain = WikiMap::getCurrentWikiDbDomain()->getId();
201        $this->fspath = realpath( $fspath ) . DIRECTORY_SEPARATOR;
202        $this->urlpath = $this->getOption( 'urlpath', "" );
203        if ( $this->urlpath !== "" && substr( $this->urlpath, -1 ) !== '/' ) {
204            $this->urlpath .= '/';
205        }
206        $this->identifier = $this->getOption( 'identifier', $dbDomain );
207        $this->compress = $this->getOption( 'compress', 'yes' ) !== 'no';
208        $this->skipRedirects = $this->hasOption( 'skip-redirects' );
209        $this->dbr = $this->getReplicaDB();
210        $this->generateNamespaces();
211        $this->timestamp = wfTimestamp( TS_ISO_8601, wfTimestampNow() );
212        $encIdentifier = rawurlencode( $this->identifier );
213        $this->findex = fopen( "{$this->fspath}sitemap-index-{$encIdentifier}.xml", 'wb' );
214        $this->main();
215    }
216
217    private function setNamespacePriorities() {
218        $sitemapNamespacesPriorities = $this->getConfig()->get( MainConfigNames::SitemapNamespacesPriorities );
219
220        // Custom main namespaces
221        $this->priorities[self::GS_MAIN] = '0.5';
222        // Custom talk namespaces
223        $this->priorities[self::GS_TALK] = '0.1';
224        // MediaWiki standard namespaces
225        $this->priorities[NS_MAIN] = '1.0';
226        $this->priorities[NS_TALK] = '0.1';
227        $this->priorities[NS_USER] = '0.5';
228        $this->priorities[NS_USER_TALK] = '0.1';
229        $this->priorities[NS_PROJECT] = '0.5';
230        $this->priorities[NS_PROJECT_TALK] = '0.1';
231        $this->priorities[NS_FILE] = '0.5';
232        $this->priorities[NS_FILE_TALK] = '0.1';
233        $this->priorities[NS_MEDIAWIKI] = '0.0';
234        $this->priorities[NS_MEDIAWIKI_TALK] = '0.1';
235        $this->priorities[NS_TEMPLATE] = '0.0';
236        $this->priorities[NS_TEMPLATE_TALK] = '0.1';
237        $this->priorities[NS_HELP] = '0.5';
238        $this->priorities[NS_HELP_TALK] = '0.1';
239        $this->priorities[NS_CATEGORY] = '0.5';
240        $this->priorities[NS_CATEGORY_TALK] = '0.1';
241
242        // Custom priorities
243        if ( $sitemapNamespacesPriorities !== false ) {
244            /**
245             * @var array $sitemapNamespacesPriorities
246             */
247            foreach ( $sitemapNamespacesPriorities as $namespace => $priority ) {
248                $float = floatval( $priority );
249                if ( $float > 1.0 ) {
250                    $priority = '1.0';
251                } elseif ( $float < 0.0 ) {
252                    $priority = '0.0';
253                }
254                $this->priorities[$namespace] = $priority;
255            }
256        }
257    }
258
259    /**
260     * Generate a one-dimensional array of existing namespaces
261     */
262    private function generateNamespaces() {
263        // Use the namespaces passed in via command line arguments if they are set.
264        $sitemapNamespacesFromConfig = $this->getOption( 'namespaces' );
265        if ( is_array( $sitemapNamespacesFromConfig ) && count( $sitemapNamespacesFromConfig ) > 0 ) {
266            $this->namespaces = $sitemapNamespacesFromConfig;
267
268            return;
269        }
270
271        // Only generate for specific namespaces if $wgSitemapNamespaces is an array.
272        $sitemapNamespaces = $this->getConfig()->get( MainConfigNames::SitemapNamespaces );
273        if ( is_array( $sitemapNamespaces ) ) {
274            $this->namespaces = $sitemapNamespaces;
275
276            return;
277        }
278
279        $res = $this->dbr->newSelectQueryBuilder()
280            ->select( [ 'page_namespace' ] )
281            ->from( 'page' )
282            ->groupBy( 'page_namespace' )
283            ->orderBy( 'page_namespace' )
284            ->caller( __METHOD__ )->fetchResultSet();
285
286        foreach ( $res as $row ) {
287            $this->namespaces[] = $row->page_namespace;
288        }
289    }
290
291    /**
292     * Get the priority of a given namespace
293     *
294     * @param int $namespace The namespace to get the priority for
295     * @return string
296     */
297    private function priority( $namespace ) {
298        return $this->priorities[$namespace] ?? $this->guessPriority( $namespace );
299    }
300
301    /**
302     * If the namespace isn't listed on the priority list return the
303     * default priority for the namespace, varies depending on whether it's
304     * a talkpage or not.
305     *
306     * @param int $namespace The namespace to get the priority for
307     * @return string
308     */
309    private function guessPriority( $namespace ) {
310        return $this->getServiceContainer()->getNamespaceInfo()->isSubject( $namespace )
311            ? $this->priorities[self::GS_MAIN]
312            : $this->priorities[self::GS_TALK];
313    }
314
315    /**
316     * Return a database resolution of all the pages in a given namespace
317     *
318     * @param int $namespace Limit the query to this namespace
319     * @return IResultWrapper
320     */
321    private function getPageRes( $namespace ) {
322        return $this->dbr->newSelectQueryBuilder()
323            ->select( [ 'page_namespace', 'page_title', 'page_touched', 'page_is_redirect', 'pp_propname' ] )
324            ->from( 'page' )
325            ->leftJoin( 'page_props', null, [ 'page_id = pp_page', 'pp_propname' => 'noindex' ] )
326            ->where( [ 'page_namespace' => $namespace ] )
327            ->caller( __METHOD__ )->fetchResultSet();
328    }
329
330    /**
331     * Main loop
332     */
333    public function main() {
334        $services = $this->getServiceContainer();
335        $contLang = $services->getContentLanguage();
336        $langConverter = $services->getLanguageConverterFactory()->getLanguageConverter( $contLang );
337
338        fwrite( $this->findex, $this->openIndex() );
339
340        foreach ( $this->namespaces as $namespace ) {
341            $res = $this->getPageRes( $namespace );
342            $this->file = false;
343            $this->generateLimit( $namespace );
344            $length = $this->limit[0];
345            $i = $smcount = 0;
346
347            $fns = $contLang->getFormattedNsText( $namespace );
348            $this->output( "$namespace ($fns)\n" );
349            $skippedRedirects = 0; // Number of redirects skipped for that namespace
350            $skippedNoindex = 0; // Number of pages with __NOINDEX__ switch for that NS
351            foreach ( $res as $row ) {
352                if ( $row->pp_propname === 'noindex' ) {
353                    $skippedNoindex++;
354                    continue;
355                }
356
357                if ( $this->skipRedirects && $row->page_is_redirect ) {
358                    $skippedRedirects++;
359                    continue;
360                }
361
362                if ( $i++ === 0
363                    || $i === $this->url_limit + 1
364                    || $length + $this->limit[1] + $this->limit[2] > $this->size_limit
365                ) {
366                    if ( $this->file !== false ) {
367                        $this->write( $this->file, $this->closeFile() );
368                        $this->close( $this->file );
369                    }
370                    $filename = $this->sitemapFilename( $namespace, $smcount++ );
371                    $this->file = $this->open( $this->fspath . $filename, 'wb' );
372                    $this->write( $this->file, $this->openFile() );
373                    fwrite( $this->findex, $this->indexEntry( $filename ) );
374                    $this->output( "\t$this->fspath$filename\n" );
375                    $length = $this->limit[0];
376                    $i = 1;
377                }
378                $title = Title::makeTitle( $row->page_namespace, $row->page_title );
379                $date = wfTimestamp( TS_ISO_8601, $row->page_touched );
380                $entry = $this->fileEntry( $title->getCanonicalURL(), $date, $this->priority( $namespace ) );
381                $length += strlen( $entry );
382                $this->write( $this->file, $entry );
383                // generate pages for language variants
384                if ( $langConverter->hasVariants() ) {
385                    $variants = $langConverter->getVariants();
386                    foreach ( $variants as $vCode ) {
387                        if ( $vCode == $contLang->getCode() ) {
388                            continue; // we don't want default variant
389                        }
390                        $entry = $this->fileEntry(
391                            $title->getCanonicalURL( [ 'variant' => $vCode ] ),
392                            $date,
393                            $this->priority( $namespace )
394                        );
395                        $length += strlen( $entry );
396                        $this->write( $this->file, $entry );
397                    }
398                }
399            }
400
401            if ( $skippedNoindex > 0 ) {
402                $this->output( "  skipped $skippedNoindex page(s) with __NOINDEX__ switch\n" );
403            }
404
405            if ( $this->skipRedirects && $skippedRedirects > 0 ) {
406                $this->output( "  skipped $skippedRedirects redirect(s)\n" );
407            }
408
409            if ( $this->file ) {
410                $this->write( $this->file, $this->closeFile() );
411                $this->close( $this->file );
412            }
413        }
414        fwrite( $this->findex, $this->closeIndex() );
415        fclose( $this->findex );
416    }
417
418    /**
419     * gzopen() / fopen() wrapper
420     *
421     * @param string $file
422     * @param string $flags
423     * @return resource
424     */
425    private function open( $file, $flags ) {
426        $resource = $this->compress ? gzopen( $file, $flags ) : fopen( $file, $flags );
427        if ( $resource === false ) {
428            throw new RuntimeException( __METHOD__
429                . " error opening file $file with flags $flags. Check permissions?" );
430        }
431
432        return $resource;
433    }
434
435    /**
436     * gzwrite() / fwrite() wrapper
437     *
438     * @param resource &$handle
439     * @param string $str
440     */
441    private function write( &$handle, $str ) {
442        if ( $handle === true || $handle === false ) {
443            throw new InvalidArgumentException( __METHOD__ . " was passed a boolean as a file handle.\n" );
444        }
445        if ( $this->compress ) {
446            gzwrite( $handle, $str );
447        } else {
448            fwrite( $handle, $str );
449        }
450    }
451
452    /**
453     * gzclose() / fclose() wrapper
454     *
455     * @param resource &$handle
456     */
457    private function close( &$handle ) {
458        if ( $this->compress ) {
459            gzclose( $handle );
460        } else {
461            fclose( $handle );
462        }
463    }
464
465    /**
466     * Get a sitemap filename
467     *
468     * @param int $namespace
469     * @param int $count
470     * @return string
471     */
472    private function sitemapFilename( $namespace, $count ) {
473        $ext = $this->compress ? '.gz' : '';
474
475        return "sitemap-{$this->identifier}-NS_$namespace-$count.xml$ext";
476    }
477
478    /**
479     * Return the XML required to open an XML file
480     *
481     * @return string
482     */
483    private function xmlHead() {
484        return '<?xml version="1.0" encoding="UTF-8"?>' . "\n";
485    }
486
487    /**
488     * Return the XML schema being used
489     *
490     * @return string
491     */
492    private function xmlSchema() {
493        return 'http://www.sitemaps.org/schemas/sitemap/0.9';
494    }
495
496    /**
497     * Return the XML required to open a sitemap index file
498     *
499     * @return string
500     */
501    private function openIndex() {
502        return $this->xmlHead() . '<sitemapindex xmlns="' . $this->xmlSchema() . '">' . "\n";
503    }
504
505    /**
506     * Return the XML for a single sitemap indexfile entry
507     *
508     * @param string $filename The filename of the sitemap file
509     * @return string
510     */
511    private function indexEntry( $filename ) {
512        return "\t<sitemap>\n" .
513            "\t\t<loc>" . wfGetServerUrl( PROTO_CANONICAL ) .
514                ( substr( $this->urlpath, 0, 1 ) === "/" ? "" : "/" ) .
515                "{$this->urlpath}$filename</loc>\n" .
516            "\t\t<lastmod>{$this->timestamp}</lastmod>\n" .
517            "\t</sitemap>\n";
518    }
519
520    /**
521     * Return the XML required to close a sitemap index file
522     *
523     * @return string
524     */
525    private function closeIndex() {
526        return "</sitemapindex>\n";
527    }
528
529    /**
530     * Return the XML required to open a sitemap file
531     *
532     * @return string
533     */
534    private function openFile() {
535        return $this->xmlHead() . '<urlset xmlns="' . $this->xmlSchema() . '">' . "\n";
536    }
537
538    /**
539     * Return the XML for a single sitemap entry
540     *
541     * @param string $url An RFC 2396 compliant URL
542     * @param string $date A ISO 8601 date
543     * @param string $priority A priority indicator, 0.0 - 1.0 inclusive with a 0.1 stepsize
544     * @return string
545     */
546    private function fileEntry( $url, $date, $priority ) {
547        return "\t<url>\n" .
548            // T36666: $url may contain bad characters such as ampersands.
549            "\t\t<loc>" . htmlspecialchars( $url ) . "</loc>\n" .
550            "\t\t<lastmod>$date</lastmod>\n" .
551            "\t\t<priority>$priority</priority>\n" .
552            "\t</url>\n";
553    }
554
555    /**
556     * Return the XML required to close sitemap file
557     *
558     * @return string
559     */
560    private function closeFile() {
561        return "</urlset>\n";
562    }
563
564    /**
565     * Populate $this->limit
566     *
567     * @param int $namespace
568     */
569    private function generateLimit( $namespace ) {
570        // T19961: make a title with the longest possible URL in this namespace
571        $title = Title::makeTitle( $namespace, str_repeat( "\u{28B81}", 63 ) . "\u{5583}" );
572
573        $this->limit = [
574            strlen( $this->openFile() ),
575            strlen( $this->fileEntry(
576                $title->getCanonicalURL(),
577                wfTimestamp( TS_ISO_8601, wfTimestamp() ),
578                $this->priority( $namespace )
579            ) ),
580            strlen( $this->closeFile() )
581        ];
582    }
583}
584
585$maintClass = GenerateSitemap::class;
586require_once RUN_MAINTENANCE_IF_MAIN;