Skip to content

Commit 71d7f3c

Browse files
milanmajchrakclaude
andcommitted
ZCU-PUB/Add a configurable noindex meta tag for item pages
dataquest-dev/dspace-customers#851 - ZCU was asked to keep two bachelor theses out of search engine results while they stay fully visible and searchable inside DSpace and on portal.zcu.cz. That is noindex, not withdraw and not discoverable=false. Adds a generic mechanism rather than hardcoding those items: - item.noIndex config list of item UUIDs / handles. Handle URLs, casing and surrounding whitespace are normalized, so 11025/9501, http://hdl.handle.net/11025/9501 and the item uuid all match. - A matching item, or any non-discoverable item, gets <meta name="robots" content="noindex, noarchive">. - No nofollow on purpose: crawlers must stay free to follow the bitstream links so they pick up the X-Robots-Tag served for the files themselves. - citation_pdf_url is dropped for such items, so Google Scholar is never handed the full text. All other citation_*/dataset_* tags are kept, so Zotero, CRIS harvesters and portal.zcu.cz keep working. Emitted from setDSOMetaTags() via addMetaTag(), which matters twice: the tag is server-side rendered, so a crawler sees it in the first HTML response rather than after hydration; and it is registered in the meta tag store, so clearMetaTags() removes it on the next route change instead of leaking the noindex onto every subsequently visited page. Shipped with an EMPTY list. The merged config is transferred to the browser unsanitized (server-init.service.ts saveAppConfigForCSR), so anything listed here becomes publicly readable in the HTML source of every page - it would publish exactly which items were suppressed. The two #851 items are de-indexed by the companion X-Robots-Tag nginx map in dspace-customers instead, which is needed for the PDFs anyway. Guards against a malformed config value: a YAML scalar (noIndex: 11025/9501) or a numeric entry would otherwise throw from the first statement of setDSOMetaTags() and strip the title, description and every citation_* tag from every item, collection and community page in the repository. Tests: metadata.service.spec.ts 31 SUCCESS (19 pre-existing + 12 new). Verified load-bearing: reverting the production change turns 6 of them red. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent c455695 commit 71d7f3c

8 files changed

Lines changed: 216 additions & 1 deletion

File tree

config/config.example.yml

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -286,6 +286,14 @@ item:
286286
undoTimeout: 10000 # 10 seconds
287287
# Show the item access status label in items lists
288288
showAccessStatuses: false
289+
# Items that must not be picked up by search engines. Their item page gets
290+
# <meta name="robots" content="noindex, noarchive"> and their citation_pdf_url tag is dropped;
291+
# the items stay fully visible and searchable inside DSpace (this is not withdraw and not
292+
# discoverable=false). Entries are item UUIDs or handles ("123456789/42"); the
293+
# http(s)://hdl.handle.net/ prefix, casing and whitespace are ignored.
294+
# Non-discoverable (private) items always get the tag, independently of this list.
295+
# This array REPLACES the default, it does not append to it.
296+
noIndex: []
289297
bitstream:
290298
# Number of entries in the bitstream list in the item view page.
291299
# Rounded to the nearest size in the list of selectable sizes on the

config/config.yml

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -192,3 +192,22 @@ item:
192192
bitstream:
193193
# Per-bitstream embargo-date badge in the Item View
194194
showAccessStatuses: true
195+
# Items that must NOT be picked up by search engines - dataquest-dev/dspace-customers#851.
196+
# Such an item stays fully visible and searchable inside DSpace and on portal.zcu.cz: this is
197+
# noindex only, NOT withdraw and NOT discoverable=false. It gets
198+
# <meta name="robots" content="noindex, noarchive"> (server-side rendered, so crawlers see it in
199+
# the first HTML response) and its citation_pdf_url tag is dropped.
200+
#
201+
# Entries may be item UUIDs or handles; the http(s)://hdl.handle.net/ prefix, casing and
202+
# surrounding whitespace are ignored. Empty list = feature off.
203+
#
204+
# DELIBERATELY EMPTY. This config is transferred to the browser unsanitized
205+
# (src/modules/app/server-init.service.ts saveAppConfigForCSR), so anything listed here becomes
206+
# publicly readable in the HTML source of every page - i.e. it would publish exactly which items
207+
# were suppressed. On a takedown-flavoured request that is not acceptable, so the #851 items are
208+
# de-indexed by the X-Robots-Tag nginx map instead (dspace-customers, customer-specific/zcu/bits).
209+
# Use this list only for cases where publishing the identifiers is fine.
210+
#
211+
# NOTE: config merging REPLACES this array (deepmerge: the source array wins), it does not
212+
# append - always list every item that must stay noindexed.
213+
noIndex: []

src/app/core/metadata/metadata.service.spec.ts

Lines changed: 91 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,10 @@ import {
1212
ItemMock,
1313
MockBitstream1,
1414
MockBitstream3,
15-
MockBitstream2
15+
MockBitstream2,
16+
NonDiscoverableItemMock
1617
} from '../../shared/mocks/item.mock';
18+
import { DSpaceObject } from '../shared/dspace-object.model';
1719
import { createSuccessfulRemoteDataObject, createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils';
1820
import { PaginatedList } from '../data/paginated-list.model';
1921
import { Bitstream } from '../shared/bitstream.model';
@@ -96,6 +98,7 @@ describe('MetadataService', () => {
9698

9799
appConfig = {
98100
item: {
101+
noIndex: [],
99102
bitstream: {
100103
pageSize: 5
101104
}
@@ -406,6 +409,93 @@ describe('MetadataService', () => {
406409
});
407410
});
408411

412+
describe('robots meta tag', () => {
413+
const noIndexTag = { name: 'robots', content: 'noindex, noarchive' };
414+
415+
const routeTo = (dso: any) => {
416+
(metadataService as any).processRouteChange({
417+
data: { value: { dso: createSuccessfulRemoteDataObject(dso) } }
418+
});
419+
tick();
420+
};
421+
422+
it('should not add a robots tag for a normal discoverable item', fakeAsync(() => {
423+
routeTo(ItemMock);
424+
expect(meta.addTag).not.toHaveBeenCalledWith(jasmine.objectContaining({ name: 'robots' }));
425+
}));
426+
427+
it('should add a robots noindex tag for a non-discoverable item', fakeAsync(() => {
428+
routeTo(NonDiscoverableItemMock);
429+
expect(meta.addTag).toHaveBeenCalledWith(noIndexTag);
430+
}));
431+
432+
it('should add a robots noindex tag when the item uuid is configured', fakeAsync(() => {
433+
appConfig.item.noIndex = ['0ec7ff22-f211-40ab-a69e-c819b0b1f357'];
434+
routeTo(ItemMock);
435+
expect(meta.addTag).toHaveBeenCalledWith(noIndexTag);
436+
}));
437+
438+
it('should add a robots noindex tag when the item handle is configured', fakeAsync(() => {
439+
appConfig.item.noIndex = ['10673/6'];
440+
routeTo(ItemMock);
441+
expect(meta.addTag).toHaveBeenCalledWith(noIndexTag);
442+
}));
443+
444+
it('should normalize handle URLs, casing and whitespace in item.noIndex', fakeAsync(() => {
445+
appConfig.item.noIndex = [' HTTP://hdl.handle.net/10673/6 '];
446+
routeTo(ItemMock);
447+
expect(meta.addTag).toHaveBeenCalledWith(noIndexTag);
448+
}));
449+
450+
it('should not add a robots tag for an item that is not configured', fakeAsync(() => {
451+
appConfig.item.noIndex = ['11025/9501', 'f4c45569-cdfc-4b3d-98df-46bfeba016b9'];
452+
routeTo(ItemMock);
453+
expect(meta.addTag).not.toHaveBeenCalledWith(jasmine.objectContaining({ name: 'robots' }));
454+
}));
455+
456+
it('should not add a robots tag for a non-Item DSpaceObject', fakeAsync(() => {
457+
appConfig.item.noIndex = ['10673/6'];
458+
routeTo(Object.assign(new DSpaceObject(), { uuid: '10673/6', handle: '10673/6', metadata: {} }));
459+
expect(meta.addTag).not.toHaveBeenCalledWith(jasmine.objectContaining({ name: 'robots' }));
460+
}));
461+
462+
it('should register the robots tag in the meta tag store so it is cleared on the next route change', fakeAsync(() => {
463+
appConfig.item.noIndex = ['10673/6'];
464+
routeTo(ItemMock);
465+
expect(store.dispatch).toHaveBeenCalledWith(new AddMetaTagAction('robots'));
466+
}));
467+
468+
it('should suppress citation_pdf_url for a noindex item', fakeAsync(() => {
469+
appConfig.item.noIndex = ['10673/6'];
470+
routeTo(ItemMock);
471+
expect(meta.addTag).not.toHaveBeenCalledWith(jasmine.objectContaining({ name: 'citation_pdf_url' }));
472+
expect(meta.addTag).toHaveBeenCalledWith(jasmine.objectContaining({ name: 'citation_title' }));
473+
}));
474+
475+
it('should keep citation_pdf_url for a normal item', fakeAsync(() => {
476+
routeTo(ItemMock);
477+
expect(meta.addTag).toHaveBeenCalledWith(jasmine.objectContaining({ name: 'citation_pdf_url' }));
478+
}));
479+
480+
it('should not break the other meta tags when item.noIndex is a scalar instead of a list', fakeAsync(() => {
481+
// A YAML scalar (noIndex: 11025/9501) is the natural single-value form and an easy slip,
482+
// since every other key under item: is a scalar. It must degrade to "feature off" and never
483+
// throw: setNoIndexTag() is the first statement of setDSOMetaTags(), so a throw here would
484+
// strip the title, description and all citation_* tags from every page in the repository.
485+
appConfig.item.noIndex = '10673/6' as any;
486+
expect(() => routeTo(ItemMock)).not.toThrow();
487+
expect(meta.addTag).toHaveBeenCalledWith(jasmine.objectContaining({ name: 'citation_title' }));
488+
expect(meta.addTag).not.toHaveBeenCalledWith(jasmine.objectContaining({ name: 'robots' }));
489+
}));
490+
491+
it('should ignore non-string entries in item.noIndex without throwing', fakeAsync(() => {
492+
appConfig.item.noIndex = [9501 as any, null, '10673/6'];
493+
expect(() => routeTo(ItemMock)).not.toThrow();
494+
expect(meta.addTag).toHaveBeenCalledWith(noIndexTag);
495+
expect(meta.addTag).toHaveBeenCalledWith(jasmine.objectContaining({ name: 'citation_title' }));
496+
}));
497+
});
498+
409499
describe(`when there's no bitstream with an allowed format on the first page`, () => {
410500
let bitstreams;
411501

src/app/core/metadata/metadata.service.ts

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,14 @@ const tagsInUseSelector =
6363
(state: MetaTagState) => state.tagsInUse,
6464
);
6565

66+
/**
67+
* Content of the robots meta tag emitted for items that must not be indexed by search engines.
68+
* `noindex` removes the page from search results, `noarchive` also suppresses cached copies.
69+
* Deliberately NO `nofollow`: crawlers must stay free to follow the bitstream links so they can
70+
* pick up the `X-Robots-Tag: noindex` served for the files themselves.
71+
*/
72+
export const NO_INDEX_META_CONTENT = 'noindex, noarchive';
73+
6674
@Injectable()
6775
export class MetadataService {
6876

@@ -147,6 +155,8 @@ export class MetadataService {
147155

148156
private setDSOMetaTags(): void {
149157

158+
this.setNoIndexTag();
159+
150160
this.setTitleTag();
151161
this.setDescriptionTag();
152162

@@ -194,6 +204,61 @@ export class MetadataService {
194204

195205
}
196206

207+
/**
208+
* Add <meta name="robots" content="noindex, noarchive"> to the <head> for Items that must not
209+
* be picked up by search engines.
210+
*
211+
* Added through addMetaTag() on purpose: that registers the tag name in the meta tag store, so
212+
* clearMetaTags() removes it again at the top of the next processRouteChange(). Adding it via
213+
* this.meta.addTag() directly (as setGenerator() does) would leak the noindex onto every
214+
* subsequently visited page.
215+
*/
216+
protected setNoIndexTag(): void {
217+
if (this.isNoIndex()) {
218+
this.addMetaTag('robots', NO_INDEX_META_CONTENT);
219+
}
220+
}
221+
222+
/**
223+
* Whether the currently resolved DSpaceObject must be excluded from search engine indexes:
224+
* either it is a non-discoverable (private) Item, or its uuid/handle is listed in the
225+
* `item.noIndex` configuration. See dataquest-dev/dspace-customers#851.
226+
*
227+
* Single predicate on purpose, so the trigger can later be replaced by (or extended with) an
228+
* item metadata field such as local.noindex without touching any call site.
229+
*/
230+
private isNoIndex(): boolean {
231+
if (!(this.currentObject.value instanceof Item)) {
232+
return false;
233+
}
234+
const item = this.currentObject.value as Item;
235+
if (item.isDiscoverable === false) {
236+
return true;
237+
}
238+
const configured = this.appConfig?.item?.noIndex;
239+
// Array.isArray, not hasNoValue: a YAML scalar (noIndex: 11025/9501) also has a length, and
240+
// throwing here would strip the title, description and every citation_* tag from every item,
241+
// collection and community page in the repository - see the first statement of setDSOMetaTags().
242+
if (!Array.isArray(configured) || configured.length === 0) {
243+
return false;
244+
}
245+
const itemIds = [item.uuid, item.handle]
246+
.filter((id) => isNotEmpty(id))
247+
.map((id) => this.normalizeNoIndexId(id));
248+
return configured.some((id: any) => typeof id === 'string' && isNotEmpty(id)
249+
&& itemIds.includes(this.normalizeNoIndexId(id)));
250+
}
251+
252+
/**
253+
* Normalize an identifier so operators can configure a bare handle (11025/9501), a handle URL
254+
* (http://hdl.handle.net/11025/9501) or an item uuid, in any casing and with stray whitespace.
255+
*/
256+
private normalizeNoIndexId(id: string): string {
257+
return id.trim().toLowerCase()
258+
.replace(/^https?:\/\/hdl\.handle\.net\//, '')
259+
.replace(/^\/+/, '');
260+
}
261+
197262
/**
198263
* Add <meta name="title" ... > to the <head>
199264
*/
@@ -349,6 +414,13 @@ export class MetadataService {
349414
* Add <meta name="citation_pdf_url" ... > to the <head>
350415
*/
351416
private setCitationPdfUrlTag(): void {
417+
if (this.isNoIndex()) {
418+
// Never hand the full text to Google Scholar for items that must not be indexed: Scholar is
419+
// a separate crawl pipeline that keys off citation_pdf_url and does not reliably honour a
420+
// landing page <meta name="robots">. All other citation_*/dataset_* tags are kept, so
421+
// Zotero, CRIS harvesters and portal.zcu.cz keep working.
422+
return;
423+
}
352424
if (this.currentObject.value instanceof Item) {
353425
const item = this.currentObject.value as Item;
354426

src/app/shared/mocks/item.mock.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -294,3 +294,17 @@ export const ItemMock: Item = Object.assign(new Item(), {
294294
)
295295
});
296296
/* eslint-enable @typescript-eslint/no-shadow */
297+
298+
/**
299+
* An Item that is not discoverable (private), mirroring upstream dspace-7_x.
300+
* `metadata` is copied into a fresh object on purpose: spec helpers such as mockType()/
301+
* mockPublisher() mutate `metadata` in place, so a shared reference would cross-contaminate
302+
* this mock and {@link ItemMock}.
303+
*/
304+
export const NonDiscoverableItemMock: Item = Object.assign(new Item(), ItemMock, {
305+
handle: '10673/7',
306+
id: '0ec7ff22-f211-40ab-a69e-c819b0b1f358',
307+
uuid: '0ec7ff22-f211-40ab-a69e-c819b0b1f358',
308+
isDiscoverable: false,
309+
metadata: Object.assign({}, ItemMock.metadata),
310+
});

src/config/default-app-config.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -281,6 +281,9 @@ export class DefaultAppConfig implements AppConfig {
281281
},
282282
// Show the item access status label in items lists
283283
showAccessStatuses: false,
284+
// Items excluded from search engine indexes (uuids or handles). Empty by default so this is
285+
// a no-op for every instance that does not configure it; see item-config.interface.ts.
286+
noIndex: [],
284287
bitstream: {
285288
// Number of entries in the bitstream list in the item view page.
286289
// Rounded to the nearest size in the list of selectable sizes on the

src/config/item-config.interface.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,13 @@ export interface ItemConfig extends Config {
77
// This is used to show the access status label of items in results lists
88
showAccessStatuses: boolean;
99

10+
// Items that must not be picked up by search engines: their item page gets
11+
// <meta name="robots" content="noindex, noarchive"> and their citation_pdf_url tag is dropped.
12+
// Entries are item UUIDs or handles ("11025/9501"); a http(s)://hdl.handle.net/ prefix, casing
13+
// and surrounding whitespace are ignored. Empty list = feature off.
14+
// Non-discoverable (private) items always get the tag, independently of this list.
15+
noIndex: string[];
16+
1017
bitstream: {
1118
// Number of entries in the bitstream list in the item view page.
1219
// Rounded to the nearest size in the list of selectable sizes on the

src/environments/environment.test.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -250,6 +250,8 @@ export const environment: BuildConfig = {
250250
},
251251
// Show the item access status label in items lists
252252
showAccessStatuses: false,
253+
// Items excluded from search engine indexes (uuids or handles)
254+
noIndex: [],
253255
bitstream: {
254256
// Number of entries in the bitstream list in the item view page.
255257
// Rounded to the nearest size in the list of selectable sizes on the

0 commit comments

Comments
 (0)