Merge lp:~mbp/launchpad/meta-description into lp:launchpad

Proposed by Martin Pool
Status: Merged
Approved by: Martin Pool
Approved revision: no longer in the source branch.
Merged at revision: 14362
Proposed branch: lp:~mbp/launchpad/meta-description
Merge into: lp:launchpad
Diff against target: 336 lines (+139/-17)
11 files modified
lib/canonical/launchpad/webapp/publisher.py (+18/-0)
lib/lp/app/browser/stringformatter.py (+12/-0)
lib/lp/app/templates/base-layout.pt (+4/-0)
lib/lp/bugs/browser/bug.py (+4/-0)
lib/lp/bugs/browser/bugtask.py (+4/-0)
lib/lp/bugs/browser/tests/test_bug_views.py (+38/-13)
lib/lp/code/browser/tests/test_branchmergeproposal.py (+17/-0)
lib/lp/registry/browser/person.py (+10/-0)
lib/lp/registry/browser/tests/test_person_view.py (+21/-0)
lib/lp/services/utils.py (+7/-3)
lib/lp/testing/factory.py (+4/-1)
To merge this branch: bzr merge lp:~mbp/launchpad/meta-description
Reviewer Review Type Date Requested Status
Raphaël Badin (community) Approve
Review via email: mp+82769@code.launchpad.net

Commit message

[r=rvb][no-qa] add meta description for bugs and bmps

Description of the change

Google sometimes generates lame page summaries for Launchpad pages, containing garbage that occurs near the start of the page.

We can give it a better clue by putting in meta description tags.

For an actual search it will often but not always generate its own summary looking at the page text, but the meta description is sometimes used there. The meta description seems to be very often used when sharing a link on g+ and facebook and at the moment it looks awful.

See:

http://googlewebmastercentral.blogspot.com/2007/09/improve-snippets-with-meta-description.html

g+ before: https://plus.google.com/112646476239496153808/posts/bMVdVYUP6XA
after: https://plus.google.com/112646476239496153808/posts/BRxTsjaUeWu

fb before: https://www.facebook.com/martin.pool/posts/107832539332513
after: https://www.facebook.com/martin.pool/posts/119663848146056

(apparently you must log in even to see fb "public" posts)

hooray!

We could do this for other pages too, but we can start with bugs and merge proposals.

To post a comment you must log in.
Revision history for this message
Martin Pool (mbp) wrote :

If there's a more zopey way to write this, let me know. I was going to try to make all view objects provide a page_description property, but I was defeated.

Revision history for this message
Martin Pool (mbp) wrote :

I worked out how to do it with less duplication in the templates: view classes can now just provide a description.

Revision history for this message
Martin Pool (mbp) wrote :

of all of these metadata patches, this is probably the most sure to have a beneficial effect

Revision history for this message
Raphaël Badin (rvb) wrote :

This looks good to land.

[0]

224 + expected_meta = Tag(
225 + 'meta description',
226 + 'meta', attrs=dict(
227 + name='description',
228 + content=True))

Don't you want to test for the presence of the (potentially cropped) 'description' ?

[1]

243 + def page_description(self):
244 + context = self.context
245 + if context.is_valid_person_or_team:
246 + return (
247 + self.context.homepage_content
248 + or self.context.teamdescription)
249 + else:
250 + return None

Don't you want to return u'' on line 250 here?

[2]

281 + self.assertThat(view.page_description,
282 + Equals(person_description))

A fascist reviewer would ask you to put a new line before "view.page_description," ;)

[3]

300 + self.assertEqual(
301 + None, view.page_description)

I think you can save a line here.

review: Approve
Revision history for this message
Martin Pool (mbp) wrote :

Thanks for the review.

I will test the actual content.

On consideration, I think I will strip email addresses always, since the description is commonly used by sharing tools and they shouldn't share the email address, even when started by an authenticated user.

> Don't you want to return u'' on line 250 here?

No I think I do want None, so the description just doesn't appear.

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'lib/canonical/launchpad/webapp/publisher.py'
2--- lib/canonical/launchpad/webapp/publisher.py 2011-11-17 17:03:52 +0000
3+++ lib/canonical/launchpad/webapp/publisher.py 2011-11-22 06:59:27 +0000
4@@ -288,6 +288,24 @@
5 pass
6
7 @property
8+ def page_description(self):
9+ """Return a string containing a description of the context.
10+
11+ Typically this is the contents of the most-descriptive text attribute
12+ of the context, by default its 'description' attribute if there is one.
13+
14+ This will be inserted into the HTML meta description, and may
15+ eventually end up in search engine summary results, or when a link to
16+ the page is shared elsewhere.
17+
18+ This may be specialized by view subclasses.
19+
20+ Do not write eg "This is a page about...", just directly describe the
21+ object on the page.
22+ """
23+ return getattr(self.context, 'description', None)
24+
25+ @property
26 def template(self):
27 """The page's template, if configured in zcml."""
28 return self.index
29
30=== modified file 'lib/lp/app/browser/stringformatter.py'
31--- lib/lp/app/browser/stringformatter.py 2011-11-03 03:24:57 +0000
32+++ lib/lp/app/browser/stringformatter.py 2011-11-22 06:59:27 +0000
33@@ -803,6 +803,16 @@
34 return self._stringtoformat
35 return obfuscate_email(self._stringtoformat)
36
37+ def strip_email(self):
38+ """Strip out things that may be email.
39+
40+ This is a variation on obfuscate_email for when we are generating a
41+ snipped for page metadata: we don't want to waste space spelling out
42+ "<email address hidden>", and we do want to strip addresses even for
43+ logged-in users in case they use the summary in a sharing tool.
44+ """
45+ return obfuscate_email(self._stringtoformat, replacement="...")
46+
47 def linkify_email(self, preloaded_person_data=None):
48 """Linkify any email address recognised in Launchpad.
49
50@@ -991,6 +1001,8 @@
51 return self.email_to_html()
52 elif name == 'obfuscate-email':
53 return self.obfuscate_email()
54+ elif name == 'strip-email':
55+ return self.strip_email()
56 elif name == 'linkify-email':
57 return self.linkify_email()
58 elif name == 'shorten':
59
60=== modified file 'lib/lp/app/templates/base-layout.pt'
61--- lib/lp/app/templates/base-layout.pt 2011-09-06 15:31:28 +0000
62+++ lib/lp/app/templates/base-layout.pt 2011-11-22 06:59:27 +0000
63@@ -52,6 +52,10 @@
64 </tal:comment>
65 <noscript></noscript>
66
67+ <meta name="description"
68+ tal:condition="view/page_description | nothing"
69+ tal:attributes="content view/page_description/fmt:strip-email/fmt:shorten/500">
70+
71 <metal:page-javascript
72 use-macro="context/@@+base-layout-macros/page-javascript" />
73 <metal:block define-slot="head_epilogue"></metal:block>
74
75=== modified file 'lib/lp/bugs/browser/bug.py'
76--- lib/lp/bugs/browser/bug.py 2011-11-16 00:09:49 +0000
77+++ lib/lp/bugs/browser/bug.py 2011-11-22 06:59:27 +0000
78@@ -544,6 +544,10 @@
79 all the pages off IBugTask instead of IBug.
80 """
81
82+ @cachedproperty
83+ def page_description(self):
84+ return IBug(self.context).description
85+
86 @property
87 def subscription(self):
88 """Return whether the current user is subscribed."""
89
90=== modified file 'lib/lp/bugs/browser/bugtask.py'
91--- lib/lp/bugs/browser/bugtask.py 2011-11-21 15:36:39 +0000
92+++ lib/lp/bugs/browser/bugtask.py 2011-11-22 06:59:27 +0000
93@@ -661,6 +661,10 @@
94 title = FormattersAPI(self.context.bug.title).obfuscate_email()
95 return smartquote('%s: "%s"') % (heading, title)
96
97+ @cachedproperty
98+ def page_description(self):
99+ return IBug(self.context).description
100+
101 @property
102 def next_url(self):
103 """Provided so returning to the page they came from works."""
104
105=== modified file 'lib/lp/bugs/browser/tests/test_bug_views.py'
106--- lib/lp/bugs/browser/tests/test_bug_views.py 2011-11-15 12:55:27 +0000
107+++ lib/lp/bugs/browser/tests/test_bug_views.py 2011-11-22 06:59:27 +0000
108@@ -11,6 +11,12 @@
109
110 from BeautifulSoup import BeautifulSoup
111
112+from testtools.matchers import (
113+ MatchesAll,
114+ Contains,
115+ Not,
116+ )
117+
118 from canonical.launchpad.webapp.publisher import canonical_url
119 from canonical.launchpad.webapp.interfaces import IOpenLaunchBag
120 from canonical.launchpad.webapp.servers import LaunchpadTestRequest
121@@ -99,25 +105,44 @@
122
123 layer = DatabaseFunctionalLayer
124
125- def getBrowserForBugWithEmail(self, email_address, no_login):
126- bug = self.factory.makeBug(
127- title="Title with %s contained" % email_address,
128- description="Description with %s contained." % email_address)
129- return self.getViewBrowser(bug, rootsite="bugs", no_login=no_login)
130+ email_address = "mark@example.com"
131+
132+ def getBrowserForBugWithEmail(self, no_login):
133+ self.bug = self.factory.makeBug(
134+ title="Title with %s contained" % self.email_address,
135+ description="Description with %s contained." % self.email_address)
136+ return self.getViewBrowser(
137+ self.bug, rootsite="bugs", no_login=no_login)
138
139 def test_user_sees_email_address(self):
140 """A logged-in user can see the email address on the page."""
141- email_address = "mark@example.com"
142- browser = self.getBrowserForBugWithEmail(
143- email_address, no_login=False)
144- self.assertEqual(7, browser.contents.count(email_address))
145+ browser = self.getBrowserForBugWithEmail(no_login=False)
146+ self.assertEqual(7, browser.contents.count(self.email_address))
147
148 def test_anonymous_sees_not_email_address(self):
149 """The anonymous user cannot see the email address on the page."""
150- email_address = "mark@example.com"
151- browser = self.getBrowserForBugWithEmail(
152- email_address, no_login=True)
153- self.assertEqual(0, browser.contents.count(email_address))
154+ browser = self.getBrowserForBugWithEmail(no_login=True)
155+ self.assertEqual(0, browser.contents.count(self.email_address))
156+
157+ def test_bug_description_in_meta_description_anonymous(self):
158+ browser = self.getBrowserForBugWithEmail(no_login=True)
159+ soup = BeautifulSoup(browser.contents)
160+ meat = soup.find('meta', dict(name='description'))
161+ self.assertThat(meat['content'], MatchesAll(
162+ Contains('Description with'),
163+ Not(Contains('@')),
164+ Contains('...'))) # Ellipsis from hidden address.
165+
166+ def test_bug_description_in_meta_description_not_anonymous(self):
167+ browser = self.getBrowserForBugWithEmail(no_login=False)
168+ soup = BeautifulSoup(browser.contents)
169+ meat = soup.find('meta', dict(name='description'))
170+ # Even logged in users get email stripped from the metadata, in case
171+ # they use a tool that copies it out.
172+ self.assertThat(meat['content'], MatchesAll(
173+ Contains('Description with'),
174+ Not(Contains('@')),
175+ Contains('...'))) # Ellipsis from hidden address.
176
177
178 class TestBugPortletSubscribers(TestCaseWithFactory):
179
180=== modified file 'lib/lp/code/browser/tests/test_branchmergeproposal.py'
181--- lib/lp/code/browser/tests/test_branchmergeproposal.py 2011-09-27 13:15:24 +0000
182+++ lib/lp/code/browser/tests/test_branchmergeproposal.py 2011-11-22 06:59:27 +0000
183@@ -30,6 +30,7 @@
184 from zope.security.interfaces import Unauthorized
185 from zope.security.proxy import removeSecurityProxy
186
187+from canonical.launchpad.webapp import canonical_url
188 from canonical.launchpad.webapp.interfaces import (
189 BrowserNotificationLevel,
190 IPrimaryContext,
191@@ -843,6 +844,22 @@
192 self.assertIn("longpoll", cache.objects)
193 self.assertIn("merge_proposal_event_key", cache.objects)
194
195+ def test_description_is_meta_description(self):
196+ description = (
197+ "I'd like to make the bmp description appear as the meta "
198+ "description: this does that "
199+ + "abcdef " * 300)
200+ bmp = self.factory.makeBranchMergeProposal(
201+ description=description)
202+ browser = self.getUserBrowser(
203+ canonical_url(bmp, rootsite='code'))
204+ expected_meta = Tag(
205+ 'meta description',
206+ 'meta', attrs=dict(
207+ name='description',
208+ content=description[:497] + '...'))
209+ self.assertThat(browser.contents, HTMLContains(expected_meta))
210+
211
212 class TestBranchMergeProposalChangeStatusOptions(TestCaseWithFactory):
213 """Test the status vocabulary generated for then +edit-status view."""
214
215=== modified file 'lib/lp/registry/browser/person.py'
216--- lib/lp/registry/browser/person.py 2011-10-19 12:44:55 +0000
217+++ lib/lp/registry/browser/person.py 2011-11-22 06:59:27 +0000
218@@ -2950,6 +2950,16 @@
219 return "%s does not use Launchpad" % context.displayname
220
221 @cachedproperty
222+ def page_description(self):
223+ context = self.context
224+ if context.is_valid_person_or_team:
225+ return (
226+ self.context.homepage_content
227+ or self.context.teamdescription)
228+ else:
229+ return None
230+
231+ @cachedproperty
232 def enable_xrds_discovery(self):
233 """Only enable discovery if person is OpenID enabled."""
234 return self.is_delegated_identity
235
236=== modified file 'lib/lp/registry/browser/tests/test_person_view.py'
237--- lib/lp/registry/browser/tests/test_person_view.py 2011-10-19 12:44:55 +0000
238+++ lib/lp/registry/browser/tests/test_person_view.py 2011-11-22 06:59:27 +0000
239@@ -10,9 +10,11 @@
240 from storm.store import Store
241 from testtools.matchers import (
242 DocTestMatches,
243+ Equals,
244 LessThan,
245 Not,
246 )
247+
248 import transaction
249 from zope.component import getUtility
250
251@@ -97,6 +99,23 @@
252 "... Asia/Kolkata (UTC+0530) ..."), doctest.ELLIPSIS
253 | doctest.NORMALIZE_WHITESPACE | doctest.REPORT_NDIFF))
254
255+ def test_person_view_page_description(self):
256+ person_description = self.factory.getUniqueString()
257+ person = self.factory.makePerson(
258+ homepage_content=person_description)
259+ view = create_initialized_view(person, '+index')
260+ self.assertThat(view.page_description,
261+ Equals(person_description))
262+
263+ def test_team_page_description(self):
264+ description = self.factory.getUniqueString()
265+ person = self.factory.makeTeam(
266+ description=description)
267+ view = create_initialized_view(person, '+index')
268+ self.assertThat(
269+ view.page_description,
270+ Equals(description))
271+
272
273 class TestPersonViewKarma(TestCaseWithFactory):
274
275@@ -891,6 +910,8 @@
276 self.assertEqual(1, len(view.errors))
277 self.assertEqual(
278 'This account is already deactivated.', view.errors[0])
279+ self.assertEqual(
280+ None, view.page_description)
281
282
283 class TestTeamInvitationView(TestCaseWithFactory):
284
285=== modified file 'lib/lp/services/utils.py'
286--- lib/lp/services/utils.py 2011-11-09 14:18:29 +0000
287+++ lib/lp/services/utils.py 2011-11-22 06:59:27 +0000
288@@ -323,18 +323,22 @@
289 """, re.VERBOSE) # ' <- font-lock turd
290
291
292-def obfuscate_email(text_to_obfuscate):
293+def obfuscate_email(text_to_obfuscate, replacement=None):
294 """Obfuscate an email address.
295
296- The email address is obfuscated as <email address hidden>.
297+ The email address is obfuscated as <email address hidden> by default,
298+ or with the given replacement.
299
300 The pattern used to identify an email address is not 2822. It strives
301 to match any possible email address embedded in the text. For example,
302 mailto:person@domain.dom and http://person:password@domain.dom both
303 match, though the http match is in fact not an email address.
304 """
305+ if replacement is None:
306+ replacement = '<email address hidden>'
307 text = re_email_address.sub(
308- r'<email address hidden>', text_to_obfuscate)
309+ replacement, text_to_obfuscate)
310+ # Avoid doubled angle brackets.
311 text = text.replace(
312 "<<email address hidden>>", "<email address hidden>")
313 return text
314
315=== modified file 'lib/lp/testing/factory.py'
316--- lib/lp/testing/factory.py 2011-11-22 00:02:48 +0000
317+++ lib/lp/testing/factory.py 2011-11-22 06:59:27 +0000
318@@ -609,7 +609,8 @@
319 self, email=None, name=None, password=None,
320 email_address_status=None, hide_email_addresses=False,
321 displayname=None, time_zone=None, latitude=None, longitude=None,
322- selfgenerated_bugnotifications=False, member_of=()):
323+ selfgenerated_bugnotifications=False, member_of=(),
324+ homepage_content=None):
325 """Create and return a new, arbitrary Person.
326
327 :param email: The email address for the new person.
328@@ -646,6 +647,8 @@
329 hide_email_addresses=hide_email_addresses)
330 naked_person = removeSecurityProxy(person)
331 naked_person._password_cleartext_cached = password
332+ if homepage_content is not None:
333+ naked_person.homepage_content = homepage_content
334
335 assert person.password is not None, (
336 'Password not set. Wrong default auth Store?')